Compare commits
No commits in common. "3a8288f5a137587a7aba25bce261e69047fe03d4" and "731a541f0044dc1ee9f3a6ddec6ec8559b3451e4" have entirely different histories.
3a8288f5a1
...
731a541f00
53
.env.bak
53
.env.bak
@ -1,53 +0,0 @@
|
|||||||
# =====================================================
|
|
||||||
# POSTGRESQL DATABASE - Local Development
|
|
||||||
# =====================================================
|
|
||||||
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
|
|
||||||
|
|
||||||
# Database credentials (bruges af docker-compose)
|
|
||||||
POSTGRES_USER=bmc_hub
|
|
||||||
POSTGRES_PASSWORD=bmc_hub
|
|
||||||
POSTGRES_DB=bmc_hub
|
|
||||||
POSTGRES_PORT=5433
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# API CONFIGURATION
|
|
||||||
# =====================================================
|
|
||||||
API_HOST=0.0.0.0
|
|
||||||
API_PORT=8001
|
|
||||||
API_RELOAD=true
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# SECURITY
|
|
||||||
# =====================================================
|
|
||||||
SECRET_KEY=change-this-in-production-use-random-string
|
|
||||||
CORS_ORIGINS=http://localhost:8000,http://localhost:3000
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# LOGGING
|
|
||||||
# =====================================================
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
LOG_FILE=logs/app.log
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# GITHUB/GITEA REPOSITORY (Optional - for reference)
|
|
||||||
# =====================================================
|
|
||||||
# Repository: https://g.bmcnetworks.dk/ct/bmc_hub
|
|
||||||
GITHUB_REPO=ct/bmc_hub
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# OLLAMA AI INTEGRATION
|
|
||||||
# =====================================================
|
|
||||||
OLLAMA_ENDPOINT=http://ai_direct.cs.blaahund.dk
|
|
||||||
OLLAMA_MODEL=qwen2.5:3b
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# e-conomic Integration (Optional)
|
|
||||||
# =====================================================
|
|
||||||
# Get credentials from e-conomic Settings -> Integrations -> API
|
|
||||||
ECONOMIC_API_URL=https://restapi.e-conomic.com
|
|
||||||
ECONOMIC_APP_SECRET_TOKEN=your_app_secret_token_here
|
|
||||||
ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
|
|
||||||
|
|
||||||
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
|
|
||||||
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
|
||||||
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
|
||||||
21
.github/copilot-instructions.md
vendored
21
.github/copilot-instructions.md
vendored
@ -33,24 +33,6 @@ BMC Hub is a central management system for BMC Networks built with **FastAPI + P
|
|||||||
- Log all external API calls before execution
|
- Log all external API calls before execution
|
||||||
- Provide dry-run mode that logs without executing
|
- Provide dry-run mode that logs without executing
|
||||||
|
|
||||||
## Design System
|
|
||||||
|
|
||||||
### "Nordic Top" Standard
|
|
||||||
The UI/UX is based on the **Nordic Top** design template found in `docs/design_reference/`.
|
|
||||||
- **Style**: Minimalist, clean, "Nordic" aesthetic.
|
|
||||||
- **Layout**: Fluid width, top navigation bar, card-based content.
|
|
||||||
- **Primary Color**: Deep Blue (`#0f4c75`).
|
|
||||||
|
|
||||||
### Mandatory Features
|
|
||||||
All frontend implementations MUST support:
|
|
||||||
1. **Dark Mode**: A toggle to switch between Light and Dark themes.
|
|
||||||
2. **Color Themes**: Architecture must allow for dynamic accent color changes (CSS Variables).
|
|
||||||
3. **Responsive Design**: Mobile-first approach using Bootstrap 5 grid.
|
|
||||||
|
|
||||||
### Reference Implementation
|
|
||||||
- **Components**: See `docs/design_reference/components.html` for the master list of UI elements.
|
|
||||||
- **Templates**: Use `index.html`, `customers.html`, `form.html` in `docs/design_reference/` as base templates.
|
|
||||||
|
|
||||||
## Development Workflows
|
## Development Workflows
|
||||||
|
|
||||||
### Local Development Setup
|
### Local Development Setup
|
||||||
@ -150,9 +132,6 @@ if settings.ECONOMIC_READ_ONLY:
|
|||||||
- For health checks: return dict with `status`, `service`, `version` keys
|
- For health checks: return dict with `status`, `service`, `version` keys
|
||||||
|
|
||||||
### File Organization
|
### File Organization
|
||||||
- **Feature-Based Structure (Vertical Slices)**: Organize code by domain/feature.
|
|
||||||
- **Pattern**: `/<feature>/frontend` and `/<feature>/backend`
|
|
||||||
- **Example**: `Customers_companies/frontend` (Templates, UI) and `Customers_companies/backend` (Routers, Models)
|
|
||||||
- **One router per domain** - don't create mega-files
|
- **One router per domain** - don't create mega-files
|
||||||
- **Services in `app/services/`** for business logic (e.g., `economic.py` for API integration)
|
- **Services in `app/services/`** for business logic (e.g., `economic.py` for API integration)
|
||||||
- **Jobs in `app/jobs/`** for scheduled tasks
|
- **Jobs in `app/jobs/`** for scheduled tasks
|
||||||
|
|||||||
@ -8,9 +8,6 @@ RUN apt-get update && apt-get install -y \
|
|||||||
git \
|
git \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
gcc \
|
gcc \
|
||||||
tesseract-ocr \
|
|
||||||
tesseract-ocr-dan \
|
|
||||||
tesseract-ocr-eng \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Build arguments for GitHub release deployment
|
# Build arguments for GitHub release deployment
|
||||||
|
|||||||
@ -1,86 +0,0 @@
|
|||||||
"""
|
|
||||||
Auth API Router - Login, Logout, Me endpoints
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, HTTPException, status, Request, Depends
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from app.core.auth_service import AuthService
|
|
||||||
from app.core.auth_dependencies import get_current_user
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(BaseModel):
|
|
||||||
access_token: str
|
|
||||||
token_type: str = "bearer"
|
|
||||||
user: dict
|
|
||||||
|
|
||||||
|
|
||||||
class LogoutRequest(BaseModel):
|
|
||||||
token_jti: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
|
||||||
async def login(request: Request, credentials: LoginRequest):
|
|
||||||
"""
|
|
||||||
Authenticate user and return JWT token
|
|
||||||
"""
|
|
||||||
ip_address = request.client.host if request.client else None
|
|
||||||
|
|
||||||
# Authenticate user
|
|
||||||
user = AuthService.authenticate_user(
|
|
||||||
username=credentials.username,
|
|
||||||
password=credentials.password,
|
|
||||||
ip_address=ip_address
|
|
||||||
)
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid username or password",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create access token
|
|
||||||
access_token = AuthService.create_access_token(
|
|
||||||
user_id=user['user_id'],
|
|
||||||
username=user['username'],
|
|
||||||
is_superadmin=user['is_superadmin']
|
|
||||||
)
|
|
||||||
|
|
||||||
return LoginResponse(
|
|
||||||
access_token=access_token,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
|
||||||
async def logout(request: LogoutRequest, current_user: dict = Depends(get_current_user)):
|
|
||||||
"""
|
|
||||||
Revoke JWT token (logout)
|
|
||||||
"""
|
|
||||||
AuthService.revoke_token(request.token_jti, current_user['id'])
|
|
||||||
|
|
||||||
return {"message": "Successfully logged out"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
|
||||||
async def get_me(current_user: dict = Depends(get_current_user)):
|
|
||||||
"""
|
|
||||||
Get current authenticated user info
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"id": current_user['id'],
|
|
||||||
"username": current_user['username'],
|
|
||||||
"email": current_user['email'],
|
|
||||||
"full_name": current_user['full_name'],
|
|
||||||
"is_superadmin": current_user['is_superadmin'],
|
|
||||||
"permissions": current_user['permissions']
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
"""
|
|
||||||
Auth Frontend Views - Login page
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
templates = Jinja2Templates(directory="app")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
|
||||||
async def login_page(request: Request):
|
|
||||||
"""
|
|
||||||
Render login page
|
|
||||||
"""
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"auth/frontend/login.html",
|
|
||||||
{"request": request}
|
|
||||||
)
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
{% extends "shared/frontend/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Login - BMC Hub{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container">
|
|
||||||
<div class="row justify-content-center align-items-center" style="min-height: 80vh;">
|
|
||||||
<div class="col-md-5 col-lg-4">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body p-4">
|
|
||||||
<div class="text-center mb-4">
|
|
||||||
<h2 class="fw-bold" style="color: var(--primary-color);">BMC Hub</h2>
|
|
||||||
<p class="text-muted">Log ind for at fortsætte</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="loginForm">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="username" class="form-label">Brugernavn</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
placeholder="Indtast brugernavn"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label">Adgangskode</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
class="form-control"
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
placeholder="Indtast adgangskode"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3 form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="rememberMe">
|
|
||||||
<label class="form-check-label" for="rememberMe">
|
|
||||||
Husk mig
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="errorMessage" class="alert alert-danger d-none" role="alert">
|
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
|
||||||
<span id="errorText"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-100 mb-3">
|
|
||||||
<i class="bi bi-box-arrow-in-right me-2"></i>
|
|
||||||
Log ind
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<small class="text-muted">
|
|
||||||
<a href="#" class="text-decoration-none">Glemt adgangskode?</a>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center mt-3">
|
|
||||||
<small class="text-muted">
|
|
||||||
BMC Networks © 2024
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const username = document.getElementById('username').value;
|
|
||||||
const password = document.getElementById('password').value;
|
|
||||||
const errorMessage = document.getElementById('errorMessage');
|
|
||||||
const errorText = document.getElementById('errorText');
|
|
||||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
||||||
|
|
||||||
// Disable submit button
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.innerHTML = '<i class="bi bi-hourglass-split me-2"></i>Logger ind...';
|
|
||||||
|
|
||||||
// Hide previous errors
|
|
||||||
errorMessage.classList.add('d-none');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ username, password })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Store token in localStorage
|
|
||||||
localStorage.setItem('access_token', data.access_token);
|
|
||||||
localStorage.setItem('user', JSON.stringify(data.user));
|
|
||||||
|
|
||||||
// Redirect to dashboard
|
|
||||||
window.location.href = '/';
|
|
||||||
} else {
|
|
||||||
// Show error
|
|
||||||
errorText.textContent = data.detail || 'Login fejlede. Tjek brugernavn og adgangskode.';
|
|
||||||
errorMessage.classList.remove('d-none');
|
|
||||||
|
|
||||||
// Re-enable submit button
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.innerHTML = '<i class="bi bi-box-arrow-in-right me-2"></i>Log ind';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error);
|
|
||||||
errorText.textContent = 'Kunne ikke forbinde til serveren. Prøv igen.';
|
|
||||||
errorMessage.classList.remove('d-none');
|
|
||||||
|
|
||||||
// Re-enable submit button
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.innerHTML = '<i class="bi bi-box-arrow-in-right me-2"></i>Log ind';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if already logged in
|
|
||||||
const token = localStorage.getItem('access_token');
|
|
||||||
if (token) {
|
|
||||||
// Verify token is still valid
|
|
||||||
fetch('/api/v1/auth/me', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (response.ok) {
|
|
||||||
// Redirect to dashboard
|
|
||||||
window.location.href = '/';
|
|
||||||
} else {
|
|
||||||
// Token invalid, clear storage
|
|
||||||
localStorage.removeItem('access_token');
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Network error, clear storage
|
|
||||||
localStorage.removeItem('access_token');
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Login page specific styles */
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
box-shadow: 0 0 0 0.2rem rgba(15, 76, 117, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode adjustments */
|
|
||||||
[data-theme="dark"] .card {
|
|
||||||
background-color: var(--dark-card);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .form-control {
|
|
||||||
background-color: var(--dark-surface);
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
color: var(--dark-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .form-control:focus {
|
|
||||||
background-color: var(--dark-surface);
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
color: var(--dark-text);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,3 +0,0 @@
|
|||||||
"""
|
|
||||||
Billing Frontend Package
|
|
||||||
"""
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,532 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="da">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Templates - BMC Hub</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding-top: 80px;
|
|
||||||
}
|
|
||||||
.navbar {
|
|
||||||
background: #ffffff;
|
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
.template-card {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
.template-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.test-modal .pdf-preview {
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<!-- Navbar -->
|
|
||||||
<nav class="navbar navbar-expand-lg fixed-top">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="/">
|
|
||||||
<i class="bi bi-speedometer2 me-2"></i>BMC Hub
|
|
||||||
</a>
|
|
||||||
<div class="navbar-nav ms-auto">
|
|
||||||
<a class="nav-link" href="/billing/supplier-invoices">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i>Tilbage til Fakturaer
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="container mt-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<div>
|
|
||||||
<h2><i class="bi bi-file-earmark-code me-2"></i>Invoice2Data Templates (YAML)</h2>
|
|
||||||
<p class="text-muted">YAML-baserede templates til automatisk faktura-udtrækning</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="templatesList" class="row">
|
|
||||||
<!-- Templates loaded here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Edit YAML Category Modal -->
|
|
||||||
<div class="modal fade" id="editYamlCategoryModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">
|
|
||||||
<i class="bi bi-pencil me-2"></i>Rediger Kategori: <span id="yamlTemplateName"></span>
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Produkt Kategori</label>
|
|
||||||
<select class="form-select" id="yamlCategorySelect">
|
|
||||||
<option value="varesalg">🛒 Varesalg</option>
|
|
||||||
<option value="drift">🔧 Drift</option>
|
|
||||||
<option value="anlæg">🏗️ Anlæg</option>
|
|
||||||
<option value="abonnement">📅 Abonnement</option>
|
|
||||||
<option value="lager">📦 Lager</option>
|
|
||||||
<option value="udlejning">🏪 Udlejning</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
|
||||||
<small>Dette ændrer default_product_category i YAML filen. Filen bliver opdateret på serveren.</small>
|
|
||||||
</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="saveYamlCategory()">
|
|
||||||
<i class="bi bi-save me-2"></i>Gem Kategori
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- View YAML Content Modal -->
|
|
||||||
<div class="modal fade" id="viewYamlModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">
|
|
||||||
<i class="bi bi-file-earmark-code me-2"></i>YAML Indhold: <span id="viewYamlTemplateName"></span>
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<pre id="yamlContent" style="background: #f8f9fa; padding: 15px; border-radius: 8px; max-height: 600px; overflow-y: auto;"><code></code></pre>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Test Modal -->
|
|
||||||
<div class="modal fade test-modal" id="testModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-xl">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">
|
|
||||||
<i class="bi bi-flask me-2"></i>Test Template: <span id="modalTemplateName"></span>
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-12 mb-3">
|
|
||||||
<label class="form-label">Vælg PDF fil til test</label>
|
|
||||||
<select class="form-select" id="testFileSelect">
|
|
||||||
<option value="">-- Vælg fil --</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="testResultsContainer" class="d-none">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-5">
|
|
||||||
<h6>PDF Preview</h6>
|
|
||||||
<div class="pdf-preview" id="testPdfPreview"></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-7">
|
|
||||||
<div id="testResults" class="alert" role="alert">
|
|
||||||
<!-- Test results shown here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="runTest()">
|
|
||||||
<i class="bi bi-play-fill me-2"></i>Kør Test
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script>
|
|
||||||
let currentTemplateId = null;
|
|
||||||
let currentTemplateIsInvoice2data = false;
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
await loadTemplates();
|
|
||||||
await loadPendingFiles();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadTemplates() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/supplier-invoices/templates');
|
|
||||||
const templates = await response.json();
|
|
||||||
|
|
||||||
const container = document.getElementById('templatesList');
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
if (templates.length === 0) {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
|
||||||
Ingen templates fundet. Klik "Ny Template" for at oprette den første.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter to only show invoice2data templates
|
|
||||||
const invoice2dataTemplates = templates.filter(t => t.template_type === 'invoice2data');
|
|
||||||
|
|
||||||
if (invoice2dataTemplates.length === 0) {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
|
||||||
Ingen YAML templates endnu. Opret .yml filer i <code>data/invoice_templates/</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
invoice2dataTemplates.forEach(template => {
|
|
||||||
const detectionPatterns = template.detection_patterns || [];
|
|
||||||
const fieldMappings = template.field_mappings || {};
|
|
||||||
const fieldCount = Object.keys(fieldMappings).filter(k => !['lines_start', 'lines_end', 'line_item'].includes(k)).length;
|
|
||||||
const category = template.default_product_category || 'varesalg';
|
|
||||||
const categoryIcons = {
|
|
||||||
'varesalg': '🛒',
|
|
||||||
'drift': '🔧',
|
|
||||||
'anlæg': '🏗️',
|
|
||||||
'abonnement': '📅',
|
|
||||||
'lager': '📦',
|
|
||||||
'udlejning': '🏪'
|
|
||||||
};
|
|
||||||
const categoryIcon = categoryIcons[category] || '📦';
|
|
||||||
|
|
||||||
container.innerHTML += `
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
<div class="card template-card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
|
||||||
<h5 class="card-title mb-0">
|
|
||||||
<i class="bi bi-file-earmark-code me-2"></i>${template.template_name}
|
|
||||||
</h5>
|
|
||||||
<span class="badge bg-success">YAML</span>
|
|
||||||
</div>
|
|
||||||
<p class="card-text text-muted mb-2">
|
|
||||||
<small>
|
|
||||||
<i class="bi bi-building me-1"></i>${template.vendor_name || 'Ingen leverandør'}
|
|
||||||
${template.vendor_cvr ? `<br><i class="bi bi-hash me-1"></i>CVR: ${template.vendor_cvr}` : ''}
|
|
||||||
<br><i class="bi bi-check-circle me-1"></i>${detectionPatterns.length} detektionsmønstre
|
|
||||||
<br><i class="bi bi-input-cursor me-1"></i>${fieldCount} felter
|
|
||||||
<br><strong>${categoryIcon} Kategori: ${category}</strong>
|
|
||||||
</small>
|
|
||||||
</p>
|
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
|
||||||
<button class="btn btn-sm btn-primary" onclick="viewYamlContent('${template.yaml_filename}')" title="Vis YAML indhold">
|
|
||||||
<i class="bi bi-file-earmark-code"></i> Vis YAML
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-warning" onclick="editYamlCategory('${template.yaml_filename}', '${category}')" title="Rediger kategori">
|
|
||||||
<i class="bi bi-pencil"></i> Kategori
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-info" onclick="openTestModal('${template.yaml_filename}', '${template.template_name}', true, ${template.vendor_id || 'null'})">
|
|
||||||
<i class="bi bi-flask"></i> Test
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load templates:', error);
|
|
||||||
alert('Kunne ikke hente templates');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPendingFiles(vendorId = null) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/pending-supplier-invoice-files');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const select = document.getElementById('testFileSelect');
|
|
||||||
select.innerHTML = '<option value="">-- Vælg fil --</option>';
|
|
||||||
|
|
||||||
// Filter by vendor if provided
|
|
||||||
let files = data.files;
|
|
||||||
if (vendorId) {
|
|
||||||
files = files.filter(f => f.vendor_matched_id == vendorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
files.forEach(file => {
|
|
||||||
select.innerHTML += `<option value="${file.file_id}">${file.filename}</option>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show message if no files for this vendor
|
|
||||||
if (vendorId && files.length === 0) {
|
|
||||||
select.innerHTML += '<option value="" disabled>Ingen filer fra denne leverandør</option>';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load files:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openTestModal(templateId, templateName, isInvoice2data = false, vendorId = null) {
|
|
||||||
currentTemplateId = templateId;
|
|
||||||
currentTemplateIsInvoice2data = isInvoice2data;
|
|
||||||
document.getElementById('modalTemplateName').textContent = templateName;
|
|
||||||
document.getElementById('testResultsContainer').classList.add('d-none');
|
|
||||||
document.getElementById('testFileSelect').value = '';
|
|
||||||
|
|
||||||
// For invoice2data templates, use vendorId if provided
|
|
||||||
if (isInvoice2data && vendorId) {
|
|
||||||
await loadPendingFiles(vendorId);
|
|
||||||
} else if (!isInvoice2data) {
|
|
||||||
// Load database template to get vendor_id
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/supplier-invoices/templates/${templateId}`);
|
|
||||||
const template = await response.json();
|
|
||||||
|
|
||||||
// Reload files filtered by this template's vendor
|
|
||||||
await loadPendingFiles(template.vendor_id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load template:', error);
|
|
||||||
await loadPendingFiles(); // Fallback to all files
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No vendor - load all files
|
|
||||||
await loadPendingFiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('testModal'));
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTest() {
|
|
||||||
const fileId = document.getElementById('testFileSelect').value;
|
|
||||||
|
|
||||||
if (!fileId) {
|
|
||||||
alert('Vælg en PDF fil');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentTemplateId) {
|
|
||||||
alert('Ingen template valgt');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Load PDF text
|
|
||||||
const fileResponse = await fetch(`/api/v1/supplier-invoices/reprocess/${fileId}`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
const fileData = await fileResponse.json();
|
|
||||||
const pdfText = fileData.pdf_text;
|
|
||||||
|
|
||||||
// Show PDF preview
|
|
||||||
document.getElementById('testPdfPreview').textContent = pdfText;
|
|
||||||
document.getElementById('testResultsContainer').classList.remove('d-none');
|
|
||||||
|
|
||||||
// Test template - use different endpoint based on type
|
|
||||||
let testUrl;
|
|
||||||
if (currentTemplateIsInvoice2data) {
|
|
||||||
testUrl = `/api/v1/supplier-invoices/templates/invoice2data/${currentTemplateId}/test`;
|
|
||||||
} else {
|
|
||||||
testUrl = `/api/v1/supplier-invoices/templates/${currentTemplateId}/test`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const testResponse = await fetch(testUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ pdf_text: pdfText })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!testResponse.ok) {
|
|
||||||
throw new Error('Test fejlede');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await testResponse.json();
|
|
||||||
|
|
||||||
// Display results
|
|
||||||
const testResults = document.getElementById('testResults');
|
|
||||||
testResults.className = 'alert';
|
|
||||||
|
|
||||||
let detectionHtml = '<h6>Detektionsmønstre:</h6><ul class="mb-2">';
|
|
||||||
for (let dr of result.detection_results) {
|
|
||||||
detectionHtml += `<li>${dr.found ? '✅' : '❌'} "${dr.pattern}" (weight: ${dr.weight})</li>`;
|
|
||||||
}
|
|
||||||
detectionHtml += '</ul>';
|
|
||||||
|
|
||||||
let extractedHtml = '<h6>Udtrækkede felter:</h6><ul class="mb-2">';
|
|
||||||
const extracted = result.extracted_fields || {};
|
|
||||||
if (Object.keys(extracted).length > 0) {
|
|
||||||
for (let [field, value] of Object.entries(extracted)) {
|
|
||||||
extractedHtml += `<li>✅ <strong>${field}:</strong> "${value}"</li>`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
extractedHtml += '<li class="text-muted">Ingen felter udtrækket</li>';
|
|
||||||
}
|
|
||||||
extractedHtml += '</ul>';
|
|
||||||
|
|
||||||
// Display line items
|
|
||||||
let linesHtml = '';
|
|
||||||
const lineItems = result.line_items || [];
|
|
||||||
if (lineItems.length > 0) {
|
|
||||||
linesHtml = `
|
|
||||||
<h6 class="mt-3">Varelinjer (${lineItems.length} stk):</h6>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm table-bordered">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
${lineItems[0].description ? '<th>Beskrivelse</th>' : ''}
|
|
||||||
${lineItems[0].quantity ? '<th>Antal</th>' : ''}
|
|
||||||
${lineItems[0].unit_price ? '<th>Pris</th>' : ''}
|
|
||||||
${lineItems.some(l => l.circuit_id || l.ip_address) ? '<th>Kredsløb/IP</th>' : ''}
|
|
||||||
${lineItems.some(l => l.location_street) ? '<th>Adresse</th>' : ''}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>`;
|
|
||||||
|
|
||||||
lineItems.forEach((line, idx) => {
|
|
||||||
const locationText = [line.location_street, line.location_zip, line.location_city].filter(x => x).join(' ');
|
|
||||||
const circuitText = line.circuit_id || line.ip_address || '';
|
|
||||||
|
|
||||||
linesHtml += `<tr>
|
|
||||||
<td>${idx + 1}</td>
|
|
||||||
${line.description ? `<td>${line.description}</td>` : ''}
|
|
||||||
${line.quantity ? `<td>${line.quantity}</td>` : ''}
|
|
||||||
${line.unit_price ? `<td>${line.unit_price}</td>` : ''}
|
|
||||||
${lineItems.some(l => l.circuit_id || l.ip_address) ? `<td><small>${circuitText}</small></td>` : ''}
|
|
||||||
${lineItems.some(l => l.location_street) ? `<td><small>${locationText}</small></td>` : ''}
|
|
||||||
</tr>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
linesHtml += `</tbody></table></div>`;
|
|
||||||
} else {
|
|
||||||
linesHtml = `
|
|
||||||
<h6 class="mt-3 text-warning">⚠️ Ingen varelinjer fundet</h6>
|
|
||||||
<p class="text-muted small">
|
|
||||||
Tjek at:<br>
|
|
||||||
• "Linje Start" markør findes i PDF'en<br>
|
|
||||||
• "Linje Slut" markør findes i PDF'en<br>
|
|
||||||
• Linje pattern matcher dine varelinjer (én linje ad gangen)<br>
|
|
||||||
<br>
|
|
||||||
Tip: Varelinjer skal være på én linje hver. Hvis din PDF har multi-line varelinjer,
|
|
||||||
skal du justere pattern eller simplificere udtrækningen.
|
|
||||||
</p>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
testResults.innerHTML = `
|
|
||||||
<h5>${result.matched ? '✅' : '❌'} Template ${result.matched ? 'MATCHER' : 'MATCHER IKKE'}</h5>
|
|
||||||
<p><strong>Confidence:</strong> ${(result.confidence * 100).toFixed(0)}% (threshold: 70%)</p>
|
|
||||||
${detectionHtml}
|
|
||||||
${extractedHtml}
|
|
||||||
${linesHtml}
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (result.matched && (Object.keys(extracted).length > 0 || lineItems.length > 0)) {
|
|
||||||
testResults.classList.add('alert-success');
|
|
||||||
} else if (result.matched) {
|
|
||||||
testResults.classList.add('alert-warning');
|
|
||||||
} else {
|
|
||||||
testResults.classList.add('alert-danger');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Test failed:', error);
|
|
||||||
const testResults = document.getElementById('testResults');
|
|
||||||
testResults.className = 'alert alert-danger';
|
|
||||||
testResults.innerHTML = `<strong>Test fejlede:</strong> ${error.message}`;
|
|
||||||
document.getElementById('testResultsContainer').classList.remove('d-none');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentYamlTemplate = null;
|
|
||||||
|
|
||||||
async function viewYamlContent(yamlFilename) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/supplier-invoices/templates/invoice2data/${yamlFilename}/content`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Kunne ikke hente YAML indhold');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
document.getElementById('viewYamlTemplateName').textContent = yamlFilename + '.yml';
|
|
||||||
document.getElementById('yamlContent').querySelector('code').textContent = data.content;
|
|
||||||
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('viewYamlModal'));
|
|
||||||
modal.show();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load YAML content:', error);
|
|
||||||
alert('❌ Kunne ikke hente YAML indhold');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function editYamlCategory(yamlFilename, currentCategory) {
|
|
||||||
currentYamlTemplate = yamlFilename;
|
|
||||||
document.getElementById('yamlTemplateName').textContent = yamlFilename + '.yml';
|
|
||||||
document.getElementById('yamlCategorySelect').value = currentCategory;
|
|
||||||
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('editYamlCategoryModal'));
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveYamlCategory() {
|
|
||||||
const newCategory = document.getElementById('yamlCategorySelect').value;
|
|
||||||
|
|
||||||
if (!currentYamlTemplate) {
|
|
||||||
alert('Ingen template valgt');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/supplier-invoices/templates/invoice2data/${currentYamlTemplate}/category`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ category: newCategory })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
alert('✅ Kategori opdateret i YAML fil');
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('editYamlCategoryModal')).hide();
|
|
||||||
await loadTemplates(); // Reload to show new category
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || 'Opdatering fejlede');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Category update failed:', error);
|
|
||||||
alert('❌ Kunne ikke opdatere kategori: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
"""
|
|
||||||
Billing Frontend Views
|
|
||||||
Serves HTML pages for billing features
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
templates = Jinja2Templates(directory="app")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/billing/supplier-invoices", response_class=HTMLResponse)
|
|
||||||
async def supplier_invoices_page(request: Request):
|
|
||||||
"""Supplier invoices (kassekladde) page"""
|
|
||||||
return templates.TemplateResponse("billing/frontend/supplier_invoices.html", {
|
|
||||||
"request": request,
|
|
||||||
"title": "Kassekladde"
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/billing/template-builder", response_class=HTMLResponse)
|
|
||||||
async def template_builder_page(request: Request):
|
|
||||||
"""Template builder for supplier invoice extraction"""
|
|
||||||
return templates.TemplateResponse("billing/frontend/template_builder.html", {
|
|
||||||
"request": request,
|
|
||||||
"title": "Template Builder"
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/billing/templates", response_class=HTMLResponse)
|
|
||||||
async def templates_list_page(request: Request):
|
|
||||||
"""Templates list and management page"""
|
|
||||||
return templates.TemplateResponse("billing/frontend/templates_list.html", {
|
|
||||||
"request": request,
|
|
||||||
"title": "Templates"
|
|
||||||
})
|
|
||||||
@ -1,308 +0,0 @@
|
|||||||
"""
|
|
||||||
Contact API Router
|
|
||||||
Handles contact CRUD operations with multi-company support
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
|
||||||
from typing import Optional, List
|
|
||||||
from app.core.database import execute_query, execute_insert, execute_update
|
|
||||||
from app.models.schemas import Contact, ContactCreate, ContactUpdate, ContactCompanyLink, CompanyInfo
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/contacts", response_model=dict)
|
|
||||||
async def get_contacts(
|
|
||||||
search: Optional[str] = None,
|
|
||||||
customer_id: Optional[int] = None,
|
|
||||||
is_active: Optional[bool] = None,
|
|
||||||
limit: int = Query(default=20, le=100),
|
|
||||||
offset: int = Query(default=0, ge=0)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get all contacts with optional filtering, search, and pagination
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Build WHERE clauses
|
|
||||||
where_clauses = []
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if search:
|
|
||||||
where_clauses.append("(c.first_name ILIKE %s OR c.last_name ILIKE %s OR c.email ILIKE %s)")
|
|
||||||
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
|
|
||||||
|
|
||||||
if is_active is not None:
|
|
||||||
where_clauses.append("c.is_active = %s")
|
|
||||||
params.append(is_active)
|
|
||||||
|
|
||||||
if customer_id is not None:
|
|
||||||
where_clauses.append("EXISTS (SELECT 1 FROM contact_companies cc WHERE cc.contact_id = c.id AND cc.customer_id = %s)")
|
|
||||||
params.append(customer_id)
|
|
||||||
|
|
||||||
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
|
||||||
|
|
||||||
# Count total
|
|
||||||
count_query = f"""
|
|
||||||
SELECT COUNT(DISTINCT c.id)
|
|
||||||
FROM contacts c
|
|
||||||
{where_sql}
|
|
||||||
"""
|
|
||||||
count_result = execute_query(count_query, tuple(params), fetchone=True)
|
|
||||||
total = count_result['count'] if count_result else 0
|
|
||||||
|
|
||||||
# Get contacts with company count
|
|
||||||
query = f"""
|
|
||||||
SELECT
|
|
||||||
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
|
|
||||||
c.title, c.department, c.is_active, c.vtiger_id,
|
|
||||||
c.created_at, c.updated_at,
|
|
||||||
COUNT(DISTINCT cc.customer_id) as company_count,
|
|
||||||
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
|
|
||||||
FROM contacts c
|
|
||||||
LEFT JOIN contact_companies cc ON c.id = cc.contact_id
|
|
||||||
LEFT JOIN customers cu ON cc.customer_id = cu.id
|
|
||||||
{where_sql}
|
|
||||||
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
|
|
||||||
c.title, c.department, c.is_active, c.vtiger_id, c.created_at, c.updated_at
|
|
||||||
ORDER BY c.last_name, c.first_name
|
|
||||||
LIMIT %s OFFSET %s
|
|
||||||
"""
|
|
||||||
params.extend([limit, offset])
|
|
||||||
|
|
||||||
contacts = execute_query(query, tuple(params)) # Default is fetchall
|
|
||||||
|
|
||||||
return {
|
|
||||||
"contacts": contacts or [],
|
|
||||||
"total": total,
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get contacts: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/contacts/{contact_id}", response_model=dict)
|
|
||||||
async def get_contact(contact_id: int):
|
|
||||||
"""
|
|
||||||
Get a single contact with all linked companies
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Get contact
|
|
||||||
contact_query = """
|
|
||||||
SELECT id, first_name, last_name, email, phone, mobile,
|
|
||||||
title, department, is_active, vtiger_id,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM contacts
|
|
||||||
WHERE id = %s
|
|
||||||
"""
|
|
||||||
contact = execute_query(contact_query, (contact_id,), fetchone=True)
|
|
||||||
|
|
||||||
if not contact:
|
|
||||||
raise HTTPException(status_code=404, detail="Contact not found")
|
|
||||||
|
|
||||||
# Get linked companies
|
|
||||||
companies_query = """
|
|
||||||
SELECT
|
|
||||||
cu.id, cu.name,
|
|
||||||
cc.is_primary, cc.role, cc.notes
|
|
||||||
FROM contact_companies cc
|
|
||||||
JOIN customers cu ON cc.customer_id = cu.id
|
|
||||||
WHERE cc.contact_id = %s
|
|
||||||
ORDER BY cc.is_primary DESC, cu.name
|
|
||||||
"""
|
|
||||||
companies = execute_query(companies_query, (contact_id,)) # Default is fetchall
|
|
||||||
|
|
||||||
contact['companies'] = companies or []
|
|
||||||
return contact
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get contact {contact_id}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/contacts", response_model=dict)
|
|
||||||
async def create_contact(contact: ContactCreate):
|
|
||||||
"""
|
|
||||||
Create a new contact and link to companies
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Insert contact
|
|
||||||
insert_query = """
|
|
||||||
INSERT INTO contacts (first_name, last_name, email, phone, mobile, title, department, is_active)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
"""
|
|
||||||
contact_id = execute_insert(
|
|
||||||
insert_query,
|
|
||||||
(contact.first_name, contact.last_name, contact.email, contact.phone,
|
|
||||||
contact.mobile, contact.title, contact.department, contact.is_active)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Link to companies
|
|
||||||
if contact.company_ids:
|
|
||||||
for idx, customer_id in enumerate(contact.company_ids):
|
|
||||||
is_primary = idx == 0 and contact.is_primary
|
|
||||||
link_query = """
|
|
||||||
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role, notes)
|
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
|
||||||
"""
|
|
||||||
execute_insert(
|
|
||||||
link_query,
|
|
||||||
(contact_id, customer_id, is_primary, contact.role, contact.notes)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return created contact
|
|
||||||
return await get_contact(contact_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create contact: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/contacts/{contact_id}", response_model=dict)
|
|
||||||
async def update_contact(contact_id: int, contact: ContactUpdate):
|
|
||||||
"""
|
|
||||||
Update a contact
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Check if contact exists
|
|
||||||
existing = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,), fetchone=True)
|
|
||||||
if not existing:
|
|
||||||
raise HTTPException(status_code=404, detail="Contact not found")
|
|
||||||
|
|
||||||
# Build update query
|
|
||||||
update_fields = []
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if contact.first_name is not None:
|
|
||||||
update_fields.append("first_name = %s")
|
|
||||||
params.append(contact.first_name)
|
|
||||||
|
|
||||||
if contact.last_name is not None:
|
|
||||||
update_fields.append("last_name = %s")
|
|
||||||
params.append(contact.last_name)
|
|
||||||
|
|
||||||
if contact.email is not None:
|
|
||||||
update_fields.append("email = %s")
|
|
||||||
params.append(contact.email)
|
|
||||||
|
|
||||||
if contact.phone is not None:
|
|
||||||
update_fields.append("phone = %s")
|
|
||||||
params.append(contact.phone)
|
|
||||||
|
|
||||||
if contact.mobile is not None:
|
|
||||||
update_fields.append("mobile = %s")
|
|
||||||
params.append(contact.mobile)
|
|
||||||
|
|
||||||
if contact.title is not None:
|
|
||||||
update_fields.append("title = %s")
|
|
||||||
params.append(contact.title)
|
|
||||||
|
|
||||||
if contact.department is not None:
|
|
||||||
update_fields.append("department = %s")
|
|
||||||
params.append(contact.department)
|
|
||||||
|
|
||||||
if contact.is_active is not None:
|
|
||||||
update_fields.append("is_active = %s")
|
|
||||||
params.append(contact.is_active)
|
|
||||||
|
|
||||||
if not update_fields:
|
|
||||||
return await get_contact(contact_id)
|
|
||||||
|
|
||||||
update_query = f"""
|
|
||||||
UPDATE contacts
|
|
||||||
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = %s
|
|
||||||
"""
|
|
||||||
params.append(contact_id)
|
|
||||||
|
|
||||||
execute_update(update_query, tuple(params))
|
|
||||||
|
|
||||||
return await get_contact(contact_id)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update contact {contact_id}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/contacts/{contact_id}")
|
|
||||||
async def delete_contact(contact_id: int):
|
|
||||||
"""
|
|
||||||
Delete a contact (cascade deletes company links)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = execute_update("DELETE FROM contacts WHERE id = %s", (contact_id,))
|
|
||||||
if result == 0:
|
|
||||||
raise HTTPException(status_code=404, detail="Contact not found")
|
|
||||||
|
|
||||||
return {"message": "Contact deleted successfully"}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete contact {contact_id}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/contacts/{contact_id}/companies")
|
|
||||||
async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
|
|
||||||
"""
|
|
||||||
Link a contact to a company
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Check if contact exists
|
|
||||||
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,), fetchone=True)
|
|
||||||
if not contact:
|
|
||||||
raise HTTPException(status_code=404, detail="Contact not found")
|
|
||||||
|
|
||||||
# Check if company exists
|
|
||||||
customer = execute_query("SELECT id FROM customers WHERE id = %s", (link.customer_id,), fetchone=True)
|
|
||||||
if not customer:
|
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
|
||||||
|
|
||||||
# Insert link (ON CONFLICT updates)
|
|
||||||
query = """
|
|
||||||
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role, notes)
|
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
|
||||||
ON CONFLICT (contact_id, customer_id)
|
|
||||||
DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role, notes = EXCLUDED.notes
|
|
||||||
"""
|
|
||||||
execute_insert(query, (contact_id, link.customer_id, link.is_primary, link.role, link.notes))
|
|
||||||
|
|
||||||
return {"message": "Contact linked to company successfully"}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to link contact to company: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/contacts/{contact_id}/companies/{customer_id}")
|
|
||||||
async def unlink_contact_from_company(contact_id: int, customer_id: int):
|
|
||||||
"""
|
|
||||||
Unlink a contact from a company
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = execute_update(
|
|
||||||
"DELETE FROM contact_companies WHERE contact_id = %s AND customer_id = %s",
|
|
||||||
(contact_id, customer_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
if result == 0:
|
|
||||||
raise HTTPException(status_code=404, detail="Link not found")
|
|
||||||
|
|
||||||
return {"message": "Contact unlinked from company successfully"}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to unlink contact from company: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
"""
|
|
||||||
Contact view routes for rendering HTML pages
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
templates = Jinja2Templates(directory="app")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/contacts", response_class=HTMLResponse)
|
|
||||||
async def contacts_page(request: Request):
|
|
||||||
"""
|
|
||||||
Render the contacts list page
|
|
||||||
"""
|
|
||||||
return templates.TemplateResponse("contacts/frontend/contacts.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/contacts/{contact_id}", response_class=HTMLResponse)
|
|
||||||
async def contact_detail_page(request: Request, contact_id: int):
|
|
||||||
"""
|
|
||||||
Render the contact detail page
|
|
||||||
"""
|
|
||||||
return templates.TemplateResponse("contacts/frontend/contact_detail.html", {
|
|
||||||
"request": request,
|
|
||||||
"contact_id": contact_id
|
|
||||||
})
|
|
||||||
@ -1,516 +0,0 @@
|
|||||||
{% extends "shared/frontend/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Kontakt Detaljer - BMC Hub{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.contact-header {
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
padding: 3rem 2rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-avatar-large {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 2rem;
|
|
||||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills-vertical {
|
|
||||||
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills-vertical .nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-radius: 8px 0 0 8px;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills-vertical .nav-link:hover {
|
|
||||||
background: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills-vertical .nav-link.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills-vertical .nav-link i {
|
|
||||||
width: 20px;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.company-card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.company-card:hover {
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-company-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- Contact Header -->
|
|
||||||
<div class="contact-header">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="contact-avatar-large me-4" id="contactAvatar">?</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="fw-bold mb-2" id="contactName">Loading...</h1>
|
|
||||||
<div class="d-flex gap-3 align-items-center">
|
|
||||||
<span id="contactTitle"></span>
|
|
||||||
<span class="badge bg-white bg-opacity-20" id="contactStatus"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button class="btn btn-light btn-sm" onclick="editContact()">
|
|
||||||
<i class="bi bi-pencil me-2"></i>Rediger
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-light btn-sm" onclick="window.location.href='/contacts'">
|
|
||||||
<i class="bi bi-arrow-left me-2"></i>Tilbage
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Layout with Sidebar Navigation -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-3 col-md-4">
|
|
||||||
<!-- Vertical Navigation -->
|
|
||||||
<ul class="nav nav-pills nav-pills-vertical flex-column" role="tablist">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" data-bs-toggle="tab" href="#overview">
|
|
||||||
<i class="bi bi-info-circle"></i>Oversigt
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#companies">
|
|
||||||
<i class="bi bi-building"></i>Firmaer
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-9 col-md-8">
|
|
||||||
<!-- Tab Content -->
|
|
||||||
<div class="tab-content">
|
|
||||||
<!-- Overview Tab -->
|
|
||||||
<div class="tab-pane fade show active" id="overview">
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="info-card">
|
|
||||||
<h5 class="fw-bold mb-4">Kontakt Oplysninger</h5>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Navn</span>
|
|
||||||
<span class="info-value" id="fullName">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Email</span>
|
|
||||||
<span class="info-value" id="email">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Telefon</span>
|
|
||||||
<span class="info-value" id="phone">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Mobil</span>
|
|
||||||
<span class="info-value" id="mobile">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="info-card">
|
|
||||||
<h5 class="fw-bold mb-4">Rolle & Stilling</h5>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Titel</span>
|
|
||||||
<span class="info-value" id="title">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Afdeling</span>
|
|
||||||
<span class="info-value" id="department">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Status</span>
|
|
||||||
<span class="info-value" id="activeStatus">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Antal Firmaer</span>
|
|
||||||
<span class="info-value" id="companyCount">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="info-card">
|
|
||||||
<h5 class="fw-bold mb-3">System Info</h5>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">vTiger ID</span>
|
|
||||||
<span class="info-value" id="vtigerId">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Oprettet</span>
|
|
||||||
<span class="info-value" id="createdAt">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Companies Tab -->
|
|
||||||
<div class="tab-pane fade" id="companies">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h5 class="fw-bold mb-0">Tilknyttede Firmaer</h5>
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="showAddCompanyModal()">
|
|
||||||
<i class="bi bi-plus-lg me-2"></i>Tilføj Firma
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="row g-4" id="companiesContainer">
|
|
||||||
<div class="col-12 text-center py-5">
|
|
||||||
<div class="spinner-border text-primary"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Company Modal -->
|
|
||||||
<div class="modal fade" id="addCompanyModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Tilføj Firma til Kontakt</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="addCompanyForm">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Vælg Firma</label>
|
|
||||||
<select class="form-select" id="companySelectModal" required>
|
|
||||||
<option value="">Vælg et firma...</option>
|
|
||||||
<!-- Populated dynamically -->
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Rolle</label>
|
|
||||||
<input type="text" class="form-control" id="roleInputModal" placeholder="Primær kontakt, Fakturering...">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Noter</label>
|
|
||||||
<textarea class="form-control" id="notesInputModal" rows="3"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="isPrimaryInputModal">
|
|
||||||
<label class="form-check-label" for="isPrimaryInputModal">
|
|
||||||
Primær kontakt for dette firma
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</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="addCompanyToContact()">
|
|
||||||
<i class="bi bi-plus-lg me-2"></i>Tilføj
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
const contactId = parseInt(window.location.pathname.split('/').pop());
|
|
||||||
let contactData = null;
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
loadContact();
|
|
||||||
loadCompaniesForSelect();
|
|
||||||
|
|
||||||
// Load companies when tab is shown
|
|
||||||
document.querySelector('a[href="#companies"]').addEventListener('shown.bs.tab', () => {
|
|
||||||
loadCompanies();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadContact() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/contacts/${contactId}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Contact not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
contactData = await response.json();
|
|
||||||
displayContact(contactData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load contact:', error);
|
|
||||||
alert('Kunne ikke indlæse kontakt');
|
|
||||||
window.location.href = '/contacts';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayContact(contact) {
|
|
||||||
// Update page title
|
|
||||||
document.title = `${contact.first_name} ${contact.last_name} - BMC Hub`;
|
|
||||||
|
|
||||||
// Header
|
|
||||||
const initials = getInitials(contact.first_name, contact.last_name);
|
|
||||||
document.getElementById('contactAvatar').textContent = initials;
|
|
||||||
document.getElementById('contactName').textContent = `${contact.first_name} ${contact.last_name}`;
|
|
||||||
document.getElementById('contactTitle').textContent = contact.title || '';
|
|
||||||
|
|
||||||
const statusBadge = contact.is_active
|
|
||||||
? '<i class="bi bi-check-circle me-1"></i>Aktiv'
|
|
||||||
: '<i class="bi bi-x-circle me-1"></i>Inaktiv';
|
|
||||||
document.getElementById('contactStatus').innerHTML = statusBadge;
|
|
||||||
|
|
||||||
// Contact Information
|
|
||||||
document.getElementById('fullName').textContent = `${contact.first_name} ${contact.last_name}`;
|
|
||||||
document.getElementById('email').textContent = contact.email || '-';
|
|
||||||
document.getElementById('phone').textContent = contact.phone || '-';
|
|
||||||
document.getElementById('mobile').textContent = contact.mobile || '-';
|
|
||||||
|
|
||||||
// Role & Position
|
|
||||||
document.getElementById('title').textContent = contact.title || '-';
|
|
||||||
document.getElementById('department').textContent = contact.department || '-';
|
|
||||||
document.getElementById('activeStatus').innerHTML = contact.is_active
|
|
||||||
? '<span class="badge bg-success">Aktiv</span>'
|
|
||||||
: '<span class="badge bg-secondary">Inaktiv</span>';
|
|
||||||
document.getElementById('companyCount').textContent = contact.companies ? contact.companies.length : 0;
|
|
||||||
|
|
||||||
// System Info
|
|
||||||
document.getElementById('vtigerId').textContent = contact.vtiger_id || '-';
|
|
||||||
document.getElementById('createdAt').textContent = new Date(contact.created_at).toLocaleString('da-DK');
|
|
||||||
|
|
||||||
// Load companies if tab is active
|
|
||||||
if (document.querySelector('a[href="#companies"]').classList.contains('active')) {
|
|
||||||
displayCompanies(contact.companies);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCompanies() {
|
|
||||||
if (!contactData) return;
|
|
||||||
displayCompanies(contactData.companies);
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayCompanies(companies) {
|
|
||||||
const container = document.getElementById('companiesContainer');
|
|
||||||
|
|
||||||
if (!companies || companies.length === 0) {
|
|
||||||
container.innerHTML = '<div class="col-12 text-center py-5 text-muted">Ingen firmaer tilknyttet</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = companies.map(company => `
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="company-card">
|
|
||||||
<button class="btn btn-sm btn-danger remove-company-btn" onclick="removeCompany(${company.id})" title="Fjern firma">
|
|
||||||
<i class="bi bi-x-lg"></i>
|
|
||||||
</button>
|
|
||||||
<div class="d-flex align-items-start mb-3">
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<h6 class="fw-bold mb-1">
|
|
||||||
<a href="/customers/${company.id}" class="text-decoration-none">${escapeHtml(company.name)}</a>
|
|
||||||
</h6>
|
|
||||||
${company.is_primary ? '<span class="badge bg-primary">Primær Kontakt</span>' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${company.role ? `
|
|
||||||
<div class="mb-2">
|
|
||||||
<small class="text-muted">Rolle:</small>
|
|
||||||
<div>${escapeHtml(company.role)}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${company.notes ? `
|
|
||||||
<div>
|
|
||||||
<small class="text-muted">Noter:</small>
|
|
||||||
<div class="small">${escapeHtml(company.notes)}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCompaniesForSelect() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/customers?limit=1000');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const select = document.getElementById('companySelectModal');
|
|
||||||
select.innerHTML = '<option value="">Vælg et firma...</option>' +
|
|
||||||
data.customers.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load companies:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAddCompanyModal() {
|
|
||||||
// Reset form
|
|
||||||
document.getElementById('addCompanyForm').reset();
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('addCompanyModal'));
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addCompanyToContact() {
|
|
||||||
const customerId = parseInt(document.getElementById('companySelectModal').value);
|
|
||||||
|
|
||||||
if (!customerId) {
|
|
||||||
alert('Vælg venligst et firma');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkData = {
|
|
||||||
customer_id: customerId,
|
|
||||||
is_primary: document.getElementById('isPrimaryInputModal').checked,
|
|
||||||
role: document.getElementById('roleInputModal').value.trim() || null,
|
|
||||||
notes: document.getElementById('notesInputModal').value.trim() || null
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/contacts/${contactId}/companies`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(linkData)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || 'Kunne ikke tilføje firma');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('addCompanyModal'));
|
|
||||||
modal.hide();
|
|
||||||
|
|
||||||
// Reload contact
|
|
||||||
await loadContact();
|
|
||||||
|
|
||||||
// Switch to companies tab
|
|
||||||
const companiesTab = new bootstrap.Tab(document.querySelector('a[href="#companies"]'));
|
|
||||||
companiesTab.show();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to add company:', error);
|
|
||||||
alert('Fejl: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeCompany(customerId) {
|
|
||||||
if (!confirm('Er du sikker på at du vil fjerne dette firma fra kontakten?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/contacts/${contactId}/companies/${customerId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || 'Kunne ikke fjerne firma');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload contact
|
|
||||||
await loadContact();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to remove company:', error);
|
|
||||||
alert('Fejl: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function editContact() {
|
|
||||||
// TODO: Open edit modal with pre-filled data
|
|
||||||
console.log('Edit contact:', contactId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitials(firstName, lastName) {
|
|
||||||
if (!firstName && !lastName) return '?';
|
|
||||||
const first = firstName ? firstName[0] : '';
|
|
||||||
const last = lastName ? lastName[0] : '';
|
|
||||||
return (first + last).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,483 +0,0 @@
|
|||||||
{% extends "shared/frontend/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Kontakter - BMC Hub{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.filter-btn {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid rgba(0,0,0,0.1);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.5rem 1.2rem;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn:hover, .filter-btn.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn {
|
|
||||||
border: 1px solid rgba(0,0,0,0.1);
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--bg-card);
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn:hover:not(:disabled) {
|
|
||||||
background: var(--accent-light);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Kontakter</h2>
|
|
||||||
<p class="text-muted mb-0">Administrer kontaktpersoner</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" id="searchInput" class="header-search" placeholder="Søg navn, email, telefon...">
|
|
||||||
<button class="btn btn-primary" onclick="showCreateContactModal()">
|
|
||||||
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4 d-flex gap-2 flex-wrap">
|
|
||||||
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
|
|
||||||
Alle Kontakter <span id="countAll" class="ms-1"></span>
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn" data-filter="active" onclick="setFilter('active')">
|
|
||||||
Aktive <span id="countActive" class="ms-1"></span>
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn" data-filter="inactive" onclick="setFilter('inactive')">
|
|
||||||
Inaktive <span id="countInactive" class="ms-1"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card p-4">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Navn</th>
|
|
||||||
<th>Kontakt Info</th>
|
|
||||||
<th>Titel</th>
|
|
||||||
<th>Firmaer</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th class="text-end">Handlinger</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="contactsTableBody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
|
||||||
<div class="text-muted small">
|
|
||||||
Viser <span id="showingStart">0</span>-<span id="showingEnd">0</span> af <span id="totalCount">0</span> kontakter
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button class="pagination-btn" id="prevBtn" onclick="previousPage()">
|
|
||||||
<i class="bi bi-chevron-left"></i> Forrige
|
|
||||||
</button>
|
|
||||||
<button class="pagination-btn" id="nextBtn" onclick="nextPage()">
|
|
||||||
Næste <i class="bi bi-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create Contact Modal -->
|
|
||||||
<div class="modal fade" id="createContactModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Opret Ny Kontakt</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="createContactForm">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Fornavn <span class="text-danger">*</span></label>
|
|
||||||
<input type="text" class="form-control" id="firstNameInput" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Efternavn <span class="text-danger">*</span></label>
|
|
||||||
<input type="text" class="form-control" id="lastNameInput" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Email</label>
|
|
||||||
<input type="email" class="form-control" id="emailInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Telefon</label>
|
|
||||||
<input type="text" class="form-control" id="phoneInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Mobil</label>
|
|
||||||
<input type="text" class="form-control" id="mobileInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Titel</label>
|
|
||||||
<input type="text" class="form-control" id="titleInput" placeholder="CEO, CTO, Manager...">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Afdeling</label>
|
|
||||||
<input type="text" class="form-control" id="departmentInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Rolle</label>
|
|
||||||
<input type="text" class="form-control" id="roleInput" placeholder="Primær kontakt, Fakturering...">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">Firmaer</label>
|
|
||||||
<select class="form-select" id="companySelect" multiple size="5">
|
|
||||||
<!-- Populated dynamically -->
|
|
||||||
</select>
|
|
||||||
<div class="form-text">Hold Ctrl/Cmd nede for at vælge flere firmaer</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="isPrimaryInput">
|
|
||||||
<label class="form-check-label" for="isPrimaryInput">
|
|
||||||
Primær kontakt (for første valgte firma)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">Noter</label>
|
|
||||||
<textarea class="form-control" id="notesInput" rows="3"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="isActiveInput" checked>
|
|
||||||
<label class="form-check-label" for="isActiveInput">
|
|
||||||
Aktiv kontakt
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</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="createContact()">
|
|
||||||
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
let currentPage = 0;
|
|
||||||
let pageSize = 20;
|
|
||||||
let currentFilter = 'all';
|
|
||||||
let searchQuery = '';
|
|
||||||
let totalContacts = 0;
|
|
||||||
|
|
||||||
// Load contacts on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
loadContacts();
|
|
||||||
loadCompaniesForSelect();
|
|
||||||
|
|
||||||
// Search with debounce
|
|
||||||
let searchTimeout;
|
|
||||||
document.getElementById('searchInput').addEventListener('input', (e) => {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
searchQuery = e.target.value;
|
|
||||||
currentPage = 0;
|
|
||||||
loadContacts();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function setFilter(filter) {
|
|
||||||
currentFilter = filter;
|
|
||||||
currentPage = 0;
|
|
||||||
|
|
||||||
// Update active button
|
|
||||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
});
|
|
||||||
event.target.classList.add('active');
|
|
||||||
|
|
||||||
loadContacts();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadContacts() {
|
|
||||||
const tbody = document.getElementById('contactsTableBody');
|
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build query parameters
|
|
||||||
let params = new URLSearchParams({
|
|
||||||
limit: pageSize,
|
|
||||||
offset: currentPage * pageSize
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchQuery) {
|
|
||||||
params.append('search', searchQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentFilter === 'active') {
|
|
||||||
params.append('is_active', 'true');
|
|
||||||
} else if (currentFilter === 'inactive') {
|
|
||||||
params.append('is_active', 'false');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/contacts?${params}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
totalContacts = data.total;
|
|
||||||
displayContacts(data.contacts);
|
|
||||||
updatePagination(data.total);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load contacts:', error);
|
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5 text-danger">Kunne ikke indlæse kontakter</td></tr>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayContacts(contacts) {
|
|
||||||
const tbody = document.getElementById('contactsTableBody');
|
|
||||||
|
|
||||||
if (!contacts || contacts.length === 0) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5 text-muted">Ingen kontakter fundet</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = contacts.map(contact => {
|
|
||||||
const initials = getInitials(contact.first_name, contact.last_name);
|
|
||||||
const statusBadge = contact.is_active
|
|
||||||
? '<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>'
|
|
||||||
: '<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
|
|
||||||
|
|
||||||
const companyCount = contact.company_count || 0;
|
|
||||||
const companyNames = contact.company_names || [];
|
|
||||||
const companyDisplay = companyNames.length > 0
|
|
||||||
? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '')
|
|
||||||
: '-';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr style="cursor: pointer;" onclick="viewContact(${contact.id})">
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="contact-avatar me-3">${initials}</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">${escapeHtml(contact.first_name + ' ' + contact.last_name)}</div>
|
|
||||||
<div class="small text-muted">${contact.department || '-'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-medium">${contact.email || '-'}</div>
|
|
||||||
<div class="small text-muted">${contact.mobile || contact.phone || '-'}</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">${contact.title || '-'}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark border" title="${companyNames.join(', ')}">
|
|
||||||
<i class="bi bi-building me-1"></i>${companyCount}
|
|
||||||
</span>
|
|
||||||
${companyDisplay !== '-' ? '<div class="small text-muted">' + companyDisplay + '</div>' : ''}
|
|
||||||
</td>
|
|
||||||
<td>${statusBadge}</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); viewContact(${contact.id})">
|
|
||||||
<i class="bi bi-eye"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); editContact(${contact.id})">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePagination(total) {
|
|
||||||
const start = currentPage * pageSize + 1;
|
|
||||||
const end = Math.min((currentPage + 1) * pageSize, total);
|
|
||||||
|
|
||||||
document.getElementById('showingStart').textContent = total > 0 ? start : 0;
|
|
||||||
document.getElementById('showingEnd').textContent = end;
|
|
||||||
document.getElementById('totalCount').textContent = total;
|
|
||||||
|
|
||||||
// Update buttons
|
|
||||||
document.getElementById('prevBtn').disabled = currentPage === 0;
|
|
||||||
document.getElementById('nextBtn').disabled = end >= total;
|
|
||||||
}
|
|
||||||
|
|
||||||
function previousPage() {
|
|
||||||
if (currentPage > 0) {
|
|
||||||
currentPage--;
|
|
||||||
loadContacts();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextPage() {
|
|
||||||
if ((currentPage + 1) * pageSize < totalContacts) {
|
|
||||||
currentPage++;
|
|
||||||
loadContacts();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewContact(contactId) {
|
|
||||||
window.location.href = `/contacts/${contactId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function editContact(contactId) {
|
|
||||||
// TODO: Open edit modal
|
|
||||||
console.log('Edit contact:', contactId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCompaniesForSelect() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/customers?limit=1000');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const select = document.getElementById('companySelect');
|
|
||||||
select.innerHTML = data.customers.map(c =>
|
|
||||||
`<option value="${c.id}">${escapeHtml(c.name)}</option>`
|
|
||||||
).join('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load companies:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCreateContactModal() {
|
|
||||||
// Reset form
|
|
||||||
document.getElementById('createContactForm').reset();
|
|
||||||
document.getElementById('isActiveInput').checked = true;
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('createContactModal'));
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createContact() {
|
|
||||||
const firstName = document.getElementById('firstNameInput').value.trim();
|
|
||||||
const lastName = document.getElementById('lastNameInput').value.trim();
|
|
||||||
|
|
||||||
if (!firstName || !lastName) {
|
|
||||||
alert('Fornavn og efternavn er påkrævet');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get selected company IDs
|
|
||||||
const companySelect = document.getElementById('companySelect');
|
|
||||||
const companyIds = Array.from(companySelect.selectedOptions).map(opt => parseInt(opt.value));
|
|
||||||
|
|
||||||
const contactData = {
|
|
||||||
first_name: firstName,
|
|
||||||
last_name: lastName,
|
|
||||||
email: document.getElementById('emailInput').value.trim() || null,
|
|
||||||
phone: document.getElementById('phoneInput').value.trim() || null,
|
|
||||||
mobile: document.getElementById('mobileInput').value.trim() || null,
|
|
||||||
title: document.getElementById('titleInput').value.trim() || null,
|
|
||||||
department: document.getElementById('departmentInput').value.trim() || null,
|
|
||||||
company_ids: companyIds,
|
|
||||||
is_primary: document.getElementById('isPrimaryInput').checked,
|
|
||||||
role: document.getElementById('roleInput').value.trim() || null,
|
|
||||||
notes: document.getElementById('notesInput').value.trim() || null,
|
|
||||||
is_active: document.getElementById('isActiveInput').checked
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/contacts', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(contactData)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || 'Kunne ikke oprette kontakt');
|
|
||||||
}
|
|
||||||
|
|
||||||
const newContact = await response.json();
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createContactModal'));
|
|
||||||
modal.hide();
|
|
||||||
|
|
||||||
// Reload contact list
|
|
||||||
await loadContacts();
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
alert('Kontakt oprettet succesfuldt!');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create contact:', error);
|
|
||||||
alert('Fejl ved oprettelse af kontakt: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitials(firstName, lastName) {
|
|
||||||
if (!firstName && !lastName) return '?';
|
|
||||||
const first = firstName ? firstName[0] : '';
|
|
||||||
const last = lastName ? lastName[0] : '';
|
|
||||||
return (first + last).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,220 +0,0 @@
|
|||||||
"""
|
|
||||||
FastAPI dependencies for authentication and authorization
|
|
||||||
Adapted from OmniSync for BMC Hub
|
|
||||||
"""
|
|
||||||
from fastapi import Depends, HTTPException, status, Request
|
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
||||||
from typing import Optional
|
|
||||||
from app.core.auth_service import AuthService
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
security = HTTPBearer()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Dependency to get current authenticated user from JWT token
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
@router.get("/endpoint")
|
|
||||||
async def my_endpoint(current_user: dict = Depends(get_current_user)):
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
token = credentials.credentials
|
|
||||||
|
|
||||||
# Verify token
|
|
||||||
payload = AuthService.verify_token(token)
|
|
||||||
|
|
||||||
if not payload:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid or expired token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get user ID
|
|
||||||
user_id = int(payload.get("sub"))
|
|
||||||
username = payload.get("username")
|
|
||||||
is_superadmin = payload.get("is_superadmin", False)
|
|
||||||
|
|
||||||
# Add IP address to user info
|
|
||||||
ip_address = request.client.host if request.client else None
|
|
||||||
|
|
||||||
# Get additional user details from database
|
|
||||||
from app.core.database import execute_query
|
|
||||||
user_details = execute_query(
|
|
||||||
"SELECT email, full_name FROM users WHERE id = %s",
|
|
||||||
(user_id,),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": user_id,
|
|
||||||
"username": username,
|
|
||||||
"email": user_details.get('email') if user_details else None,
|
|
||||||
"full_name": user_details.get('full_name') if user_details else None,
|
|
||||||
"is_superadmin": is_superadmin,
|
|
||||||
"ip_address": ip_address,
|
|
||||||
"permissions": AuthService.get_user_permissions(user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def get_optional_user(
|
|
||||||
request: Request,
|
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
|
||||||
) -> Optional[dict]:
|
|
||||||
"""
|
|
||||||
Dependency to get current user if authenticated, None otherwise
|
|
||||||
Allows endpoints that work both with and without authentication
|
|
||||||
"""
|
|
||||||
if not credentials:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await get_current_user(request, credentials)
|
|
||||||
except HTTPException:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def require_permission(permission: str):
|
|
||||||
"""
|
|
||||||
Dependency factory to require specific permission
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
@router.post("/products", dependencies=[Depends(require_permission("products.create"))])
|
|
||||||
async def create_product(...):
|
|
||||||
...
|
|
||||||
|
|
||||||
Or with user access:
|
|
||||||
@router.post("/products")
|
|
||||||
async def create_product(
|
|
||||||
current_user: dict = Depends(require_permission("products.create"))
|
|
||||||
):
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
async def permission_checker(current_user: dict = Depends(get_current_user)) -> dict:
|
|
||||||
user_id = current_user["id"]
|
|
||||||
username = current_user["username"]
|
|
||||||
|
|
||||||
# Superadmins have all permissions
|
|
||||||
if current_user.get("is_superadmin"):
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
# Check permission
|
|
||||||
if not AuthService.user_has_permission(user_id, permission):
|
|
||||||
logger.warning(
|
|
||||||
f"⚠️ Permission denied: {username} attempted {permission}"
|
|
||||||
)
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"Missing required permission: {permission}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
return permission_checker
|
|
||||||
|
|
||||||
|
|
||||||
def require_superadmin(current_user: dict = Depends(get_current_user)) -> dict:
|
|
||||||
"""
|
|
||||||
Dependency to require superadmin access
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
@router.post("/admin/users")
|
|
||||||
async def create_user(current_user: dict = Depends(require_superadmin)):
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
if not current_user.get("is_superadmin"):
|
|
||||||
logger.warning(
|
|
||||||
f"⚠️ Superadmin required: {current_user['username']} attempted admin access"
|
|
||||||
)
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Superadmin access required"
|
|
||||||
)
|
|
||||||
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
def require_any_permission(*permissions: str):
|
|
||||||
"""
|
|
||||||
Dependency factory to require ANY of the specified permissions
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
@router.get("/reports")
|
|
||||||
async def get_reports(
|
|
||||||
current_user: dict = Depends(require_any_permission("reports.view", "reports.admin"))
|
|
||||||
):
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
async def permission_checker(current_user: dict = Depends(get_current_user)) -> dict:
|
|
||||||
user_id = current_user["id"]
|
|
||||||
|
|
||||||
# Superadmins have all permissions
|
|
||||||
if current_user.get("is_superadmin"):
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
# Check if user has ANY of the permissions
|
|
||||||
for permission in permissions:
|
|
||||||
if AuthService.user_has_permission(user_id, permission):
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
# None of the permissions matched
|
|
||||||
logger.warning(
|
|
||||||
f"⚠️ Permission denied: {current_user['username']} "
|
|
||||||
f"attempted one of: {', '.join(permissions)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"Missing required permission. Need one of: {', '.join(permissions)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return permission_checker
|
|
||||||
|
|
||||||
|
|
||||||
def require_all_permissions(*permissions: str):
|
|
||||||
"""
|
|
||||||
Dependency factory to require ALL of the specified permissions
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
@router.post("/sensitive-operation")
|
|
||||||
async def sensitive_op(
|
|
||||||
current_user: dict = Depends(require_all_permissions("admin.access", "data.export"))
|
|
||||||
):
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
async def permission_checker(current_user: dict = Depends(get_current_user)) -> dict:
|
|
||||||
user_id = current_user["id"]
|
|
||||||
|
|
||||||
# Superadmins have all permissions
|
|
||||||
if current_user.get("is_superadmin"):
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
# Check if user has ALL permissions
|
|
||||||
missing_permissions = []
|
|
||||||
for permission in permissions:
|
|
||||||
if not AuthService.user_has_permission(user_id, permission):
|
|
||||||
missing_permissions.append(permission)
|
|
||||||
|
|
||||||
if missing_permissions:
|
|
||||||
logger.warning(
|
|
||||||
f"⚠️ Permission denied: {current_user['username']} "
|
|
||||||
f"missing: {', '.join(missing_permissions)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"Missing required permissions: {', '.join(missing_permissions)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
return permission_checker
|
|
||||||
@ -1,313 +0,0 @@
|
|||||||
"""
|
|
||||||
Authentication Service - Håndterer login, JWT tokens, password hashing
|
|
||||||
Adapted from OmniSync for BMC Hub
|
|
||||||
"""
|
|
||||||
from typing import Optional, Dict, List
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import hashlib
|
|
||||||
import secrets
|
|
||||||
import jwt
|
|
||||||
from app.core.database import execute_query, execute_insert, execute_update
|
|
||||||
from app.core.config import settings
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# JWT Settings
|
|
||||||
SECRET_KEY = getattr(settings, 'JWT_SECRET_KEY', 'your-secret-key-change-in-production')
|
|
||||||
ALGORITHM = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer
|
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
|
||||||
"""Service for authentication and authorization"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def hash_password(password: str) -> str:
|
|
||||||
"""
|
|
||||||
Hash password using SHA256
|
|
||||||
I produktion: Brug bcrypt eller argon2!
|
|
||||||
"""
|
|
||||||
return hashlib.sha256(password.encode()).hexdigest()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
||||||
"""Verify password against hash"""
|
|
||||||
return AuthService.hash_password(plain_password) == hashed_password
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_access_token(user_id: int, username: str, is_superadmin: bool = False) -> str:
|
|
||||||
"""
|
|
||||||
Create JWT access token
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User ID
|
|
||||||
username: Username
|
|
||||||
is_superadmin: Whether user is superadmin
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JWT token string
|
|
||||||
"""
|
|
||||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
||||||
jti = secrets.token_urlsafe(32) # JWT ID for token revocation
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"sub": str(user_id),
|
|
||||||
"username": username,
|
|
||||||
"is_superadmin": is_superadmin,
|
|
||||||
"exp": expire,
|
|
||||||
"iat": datetime.utcnow(),
|
|
||||||
"jti": jti
|
|
||||||
}
|
|
||||||
|
|
||||||
token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
|
||||||
|
|
||||||
# Store session for token revocation
|
|
||||||
execute_insert(
|
|
||||||
"""INSERT INTO sessions (user_id, token_jti, expires_at)
|
|
||||||
VALUES (%s, %s, %s)""",
|
|
||||||
(user_id, jti, expire)
|
|
||||||
)
|
|
||||||
|
|
||||||
return token
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def verify_token(token: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Verify and decode JWT token
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with user info or None if invalid
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
||||||
|
|
||||||
# Check if token is revoked
|
|
||||||
jti = payload.get('jti')
|
|
||||||
if jti:
|
|
||||||
session = execute_query(
|
|
||||||
"SELECT revoked FROM sessions WHERE token_jti = %s",
|
|
||||||
(jti,),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if session and session.get('revoked'):
|
|
||||||
logger.warning(f"⚠️ Revoked token used: {jti[:10]}...")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return payload
|
|
||||||
|
|
||||||
except jwt.ExpiredSignatureError:
|
|
||||||
logger.warning("⚠️ Expired token")
|
|
||||||
return None
|
|
||||||
except jwt.InvalidTokenError as e:
|
|
||||||
logger.warning(f"⚠️ Invalid token: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def authenticate_user(username: str, password: str, ip_address: Optional[str] = None) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Authenticate user with username/password
|
|
||||||
|
|
||||||
Args:
|
|
||||||
username: Username
|
|
||||||
password: Plain text password
|
|
||||||
ip_address: Client IP address (for logging)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
User dict if successful, None otherwise
|
|
||||||
"""
|
|
||||||
# Get user
|
|
||||||
user = execute_query(
|
|
||||||
"""SELECT id, username, email, password_hash, full_name,
|
|
||||||
is_active, is_superadmin, failed_login_attempts, locked_until
|
|
||||||
FROM users
|
|
||||||
WHERE username = %s OR email = %s""",
|
|
||||||
(username, username),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
logger.warning(f"❌ Login failed: User not found - {username}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if account is active
|
|
||||||
if not user['is_active']:
|
|
||||||
logger.warning(f"❌ Login failed: Account disabled - {username}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if account is locked
|
|
||||||
if user['locked_until']:
|
|
||||||
locked_until = user['locked_until']
|
|
||||||
if datetime.now() < locked_until:
|
|
||||||
logger.warning(f"❌ Login failed: Account locked - {username}")
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
# Unlock account
|
|
||||||
execute_update(
|
|
||||||
"UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE id = %s",
|
|
||||||
(user['id'],)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify password
|
|
||||||
if not AuthService.verify_password(password, user['password_hash']):
|
|
||||||
# Increment failed attempts
|
|
||||||
failed_attempts = user['failed_login_attempts'] + 1
|
|
||||||
|
|
||||||
if failed_attempts >= 5:
|
|
||||||
# Lock account for 30 minutes
|
|
||||||
locked_until = datetime.now() + timedelta(minutes=30)
|
|
||||||
execute_update(
|
|
||||||
"""UPDATE users
|
|
||||||
SET failed_login_attempts = %s, locked_until = %s
|
|
||||||
WHERE id = %s""",
|
|
||||||
(failed_attempts, locked_until, user['id'])
|
|
||||||
)
|
|
||||||
logger.warning(f"🔒 Account locked due to failed attempts: {username}")
|
|
||||||
else:
|
|
||||||
execute_update(
|
|
||||||
"UPDATE users SET failed_login_attempts = %s WHERE id = %s",
|
|
||||||
(failed_attempts, user['id'])
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Success! Reset failed attempts and update last login
|
|
||||||
execute_update(
|
|
||||||
"""UPDATE users
|
|
||||||
SET failed_login_attempts = 0,
|
|
||||||
locked_until = NULL,
|
|
||||||
last_login_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = %s""",
|
|
||||||
(user['id'],)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"✅ User logged in: {username} from IP: {ip_address}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'user_id': user['id'],
|
|
||||||
'username': user['username'],
|
|
||||||
'email': user['email'],
|
|
||||||
'full_name': user['full_name'],
|
|
||||||
'is_superadmin': bool(user['is_superadmin'])
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def revoke_token(jti: str, user_id: int):
|
|
||||||
"""Revoke a JWT token"""
|
|
||||||
execute_update(
|
|
||||||
"UPDATE sessions SET revoked = TRUE WHERE token_jti = %s AND user_id = %s",
|
|
||||||
(jti, user_id)
|
|
||||||
)
|
|
||||||
logger.info(f"🔒 Token revoked for user {user_id}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_user_permissions(user_id: int) -> List[str]:
|
|
||||||
"""
|
|
||||||
Get all permissions for a user (through their groups)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of permission codes
|
|
||||||
"""
|
|
||||||
# Check if user is superadmin first
|
|
||||||
user = execute_query(
|
|
||||||
"SELECT is_superadmin FROM users WHERE id = %s",
|
|
||||||
(user_id,),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Superadmins have all permissions
|
|
||||||
if user and user['is_superadmin']:
|
|
||||||
all_perms = execute_query("SELECT code FROM permissions")
|
|
||||||
return [p['code'] for p in all_perms] if all_perms else []
|
|
||||||
|
|
||||||
# Get permissions through groups
|
|
||||||
perms = execute_query("""
|
|
||||||
SELECT DISTINCT p.code
|
|
||||||
FROM permissions p
|
|
||||||
JOIN group_permissions gp ON p.id = gp.permission_id
|
|
||||||
JOIN user_groups ug ON gp.group_id = ug.group_id
|
|
||||||
WHERE ug.user_id = %s
|
|
||||||
""", (user_id,))
|
|
||||||
|
|
||||||
return [p['code'] for p in perms] if perms else []
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def user_has_permission(user_id: int, permission_code: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if user has specific permission
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User ID
|
|
||||||
permission_code: Permission code (e.g., 'customers.view')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if user has permission
|
|
||||||
"""
|
|
||||||
# Superadmins have all permissions
|
|
||||||
user = execute_query(
|
|
||||||
"SELECT is_superadmin FROM users WHERE id = %s",
|
|
||||||
(user_id,),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if user and user['is_superadmin']:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check if user has permission through groups
|
|
||||||
result = execute_query("""
|
|
||||||
SELECT COUNT(*) as cnt
|
|
||||||
FROM permissions p
|
|
||||||
JOIN group_permissions gp ON p.id = gp.permission_id
|
|
||||||
JOIN user_groups ug ON gp.group_id = ug.group_id
|
|
||||||
WHERE ug.user_id = %s AND p.code = %s
|
|
||||||
""", (user_id, permission_code), fetchone=True)
|
|
||||||
|
|
||||||
return bool(result and result['cnt'] > 0)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_user(
|
|
||||||
username: str,
|
|
||||||
email: str,
|
|
||||||
password: str,
|
|
||||||
full_name: Optional[str] = None,
|
|
||||||
is_superadmin: bool = False
|
|
||||||
) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Create a new user
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
New user ID or None if failed
|
|
||||||
"""
|
|
||||||
password_hash = AuthService.hash_password(password)
|
|
||||||
|
|
||||||
user_id = execute_insert(
|
|
||||||
"""INSERT INTO users
|
|
||||||
(username, email, password_hash, full_name, is_superadmin)
|
|
||||||
VALUES (%s, %s, %s, %s, %s) RETURNING id""",
|
|
||||||
(username, email, password_hash, full_name, is_superadmin)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"👤 User created: {username} (ID: {user_id})")
|
|
||||||
return user_id
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def change_password(user_id: int, new_password: str):
|
|
||||||
"""Change user password"""
|
|
||||||
password_hash = AuthService.hash_password(new_password)
|
|
||||||
|
|
||||||
execute_update(
|
|
||||||
"UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
|
||||||
(password_hash, user_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Revoke all existing sessions
|
|
||||||
execute_update(
|
|
||||||
"UPDATE sessions SET revoked = TRUE WHERE user_id = %s",
|
|
||||||
(user_id,)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"🔑 Password changed for user {user_id}")
|
|
||||||
@ -33,22 +33,9 @@ class Settings(BaseSettings):
|
|||||||
ECONOMIC_READ_ONLY: bool = True
|
ECONOMIC_READ_ONLY: bool = True
|
||||||
ECONOMIC_DRY_RUN: bool = True
|
ECONOMIC_DRY_RUN: bool = True
|
||||||
|
|
||||||
# Ollama AI Integration
|
|
||||||
OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk"
|
|
||||||
OLLAMA_MODEL: str = "qwen2.5-coder:7b" # qwen2.5-coder fungerer bedre til JSON udtrækning
|
|
||||||
|
|
||||||
# Company Info
|
|
||||||
OWN_CVR: str = "29522790" # BMC Denmark ApS - ignore when detecting vendors
|
|
||||||
|
|
||||||
# File Upload
|
|
||||||
UPLOAD_DIR: str = "uploads"
|
|
||||||
MAX_FILE_SIZE_MB: int = 50
|
|
||||||
ALLOWED_EXTENSIONS: List[str] = [".pdf", ".png", ".jpg", ".jpeg", ".txt", ".csv"]
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
case_sensitive = True
|
case_sensitive = True
|
||||||
extra = "ignore" # Ignore extra fields from .env
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@ -55,103 +55,19 @@ def get_db():
|
|||||||
release_db_connection(conn)
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
|
||||||
def execute_query(query: str, params: tuple = None, fetchone: bool = False):
|
def execute_query(query: str, params: tuple = None, fetch: bool = True):
|
||||||
"""
|
"""Execute a SQL query and return results"""
|
||||||
Execute a SQL query and return results
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: SQL query string
|
|
||||||
params: Query parameters tuple
|
|
||||||
fetchone: If True, return single row dict, otherwise list of dicts
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Single dict if fetchone=True, otherwise list of dicts
|
|
||||||
"""
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
cursor.execute(query, params or ())
|
cursor.execute(query, params)
|
||||||
|
if fetch:
|
||||||
# Check if this is a write operation (INSERT, UPDATE, DELETE)
|
return cursor.fetchall()
|
||||||
query_upper = query.strip().upper()
|
conn.commit()
|
||||||
is_write = any(query_upper.startswith(cmd) for cmd in ['INSERT', 'UPDATE', 'DELETE'])
|
return cursor.rowcount
|
||||||
|
|
||||||
if fetchone:
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if is_write:
|
|
||||||
conn.commit()
|
|
||||||
return dict(row) if row else None
|
|
||||||
else:
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
if is_write:
|
|
||||||
conn.commit()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
logger.error(f"Query error: {e}")
|
logger.error(f"Query error: {e}")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
release_db_connection(conn)
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
|
||||||
def execute_insert(query: str, params: tuple = ()) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Execute an INSERT query and return last row id
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: SQL INSERT query (will add RETURNING id if not present)
|
|
||||||
params: Query parameters tuple
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Last inserted row ID or None
|
|
||||||
"""
|
|
||||||
conn = get_db_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
|
||||||
# PostgreSQL requires RETURNING clause
|
|
||||||
if "RETURNING" not in query.upper():
|
|
||||||
query = query.rstrip(";") + " RETURNING id"
|
|
||||||
cursor.execute(query, params)
|
|
||||||
result = cursor.fetchone()
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# If result exists, return the first column value (typically ID)
|
|
||||||
if result:
|
|
||||||
# If it's a dict, get first value
|
|
||||||
if isinstance(result, dict):
|
|
||||||
return list(result.values())[0]
|
|
||||||
# If it's a tuple/list, get first element
|
|
||||||
return result[0]
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
conn.rollback()
|
|
||||||
logger.error(f"Insert error: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
release_db_connection(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def execute_update(query: str, params: tuple = ()) -> int:
|
|
||||||
"""
|
|
||||||
Execute an UPDATE/DELETE query and return affected rows
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: SQL UPDATE/DELETE query
|
|
||||||
params: Query parameters tuple
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of affected rows
|
|
||||||
"""
|
|
||||||
conn = get_db_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
|
||||||
cursor.execute(query, params)
|
|
||||||
rowcount = cursor.rowcount
|
|
||||||
conn.commit()
|
|
||||||
return rowcount
|
|
||||||
except Exception as e:
|
|
||||||
conn.rollback()
|
|
||||||
logger.error(f"Update error: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
release_db_connection(conn)
|
|
||||||
|
|||||||
@ -1,357 +0,0 @@
|
|||||||
"""
|
|
||||||
Customers Router
|
|
||||||
API endpoints for customer management
|
|
||||||
Adapted from OmniSync for BMC Hub
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
|
||||||
from typing import List, Optional, Dict
|
|
||||||
from pydantic import BaseModel
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_insert, execute_update
|
|
||||||
from app.services.cvr_service import get_cvr_service
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# Pydantic Models
|
|
||||||
class CustomerBase(BaseModel):
|
|
||||||
name: str
|
|
||||||
cvr_number: Optional[str] = None
|
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
address: Optional[str] = None
|
|
||||||
city: Optional[str] = None
|
|
||||||
postal_code: Optional[str] = None
|
|
||||||
country: Optional[str] = "DK"
|
|
||||||
website: Optional[str] = None
|
|
||||||
is_active: Optional[bool] = True
|
|
||||||
invoice_email: Optional[str] = None
|
|
||||||
mobile_phone: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerCreate(CustomerBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerUpdate(BaseModel):
|
|
||||||
name: Optional[str] = None
|
|
||||||
cvr_number: Optional[str] = None
|
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
address: Optional[str] = None
|
|
||||||
city: Optional[str] = None
|
|
||||||
postal_code: Optional[str] = None
|
|
||||||
country: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
invoice_email: Optional[str] = None
|
|
||||||
mobile_phone: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ContactCreate(BaseModel):
|
|
||||||
first_name: str
|
|
||||||
last_name: str
|
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
mobile: Optional[str] = None
|
|
||||||
title: Optional[str] = None
|
|
||||||
department: Optional[str] = None
|
|
||||||
is_primary: Optional[bool] = False
|
|
||||||
role: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/customers")
|
|
||||||
async def list_customers(
|
|
||||||
limit: int = Query(default=50, ge=1, le=1000),
|
|
||||||
offset: int = Query(default=0, ge=0),
|
|
||||||
search: Optional[str] = Query(default=None),
|
|
||||||
source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None
|
|
||||||
is_active: Optional[bool] = Query(default=None)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
List customers with pagination and filtering
|
|
||||||
|
|
||||||
Args:
|
|
||||||
limit: Maximum number of customers to return
|
|
||||||
offset: Number of customers to skip
|
|
||||||
search: Search term for name, email, cvr, phone, city
|
|
||||||
source: Filter by source ('vtiger' or 'local')
|
|
||||||
is_active: Filter by active status
|
|
||||||
"""
|
|
||||||
# Build query
|
|
||||||
query = """
|
|
||||||
SELECT
|
|
||||||
c.*,
|
|
||||||
COUNT(DISTINCT cc.contact_id) as contact_count
|
|
||||||
FROM customers c
|
|
||||||
LEFT JOIN contact_companies cc ON cc.customer_id = c.id
|
|
||||||
WHERE 1=1
|
|
||||||
"""
|
|
||||||
params = []
|
|
||||||
|
|
||||||
# Add search filter
|
|
||||||
if search:
|
|
||||||
query += """ AND (
|
|
||||||
c.name ILIKE %s OR
|
|
||||||
c.email ILIKE %s OR
|
|
||||||
c.cvr_number ILIKE %s OR
|
|
||||||
c.phone ILIKE %s OR
|
|
||||||
c.city ILIKE %s
|
|
||||||
)"""
|
|
||||||
search_term = f"%{search}%"
|
|
||||||
params.extend([search_term] * 5)
|
|
||||||
|
|
||||||
# Add source filter
|
|
||||||
if source == 'vtiger':
|
|
||||||
query += " AND c.vtiger_id IS NOT NULL"
|
|
||||||
elif source == 'local':
|
|
||||||
query += " AND c.vtiger_id IS NULL"
|
|
||||||
|
|
||||||
# Add active filter
|
|
||||||
if is_active is not None:
|
|
||||||
query += " AND c.is_active = %s"
|
|
||||||
params.append(is_active)
|
|
||||||
|
|
||||||
query += """
|
|
||||||
GROUP BY c.id
|
|
||||||
ORDER BY c.name
|
|
||||||
LIMIT %s OFFSET %s
|
|
||||||
"""
|
|
||||||
params.extend([limit, offset])
|
|
||||||
|
|
||||||
rows = execute_query(query, tuple(params))
|
|
||||||
|
|
||||||
# Get total count
|
|
||||||
count_query = "SELECT COUNT(*) as total FROM customers WHERE 1=1"
|
|
||||||
count_params = []
|
|
||||||
|
|
||||||
if search:
|
|
||||||
count_query += """ AND (
|
|
||||||
name ILIKE %s OR
|
|
||||||
email ILIKE %s OR
|
|
||||||
cvr_number ILIKE %s OR
|
|
||||||
phone ILIKE %s OR
|
|
||||||
city ILIKE %s
|
|
||||||
)"""
|
|
||||||
count_params.extend([search_term] * 5)
|
|
||||||
|
|
||||||
if source == 'vtiger':
|
|
||||||
count_query += " AND vtiger_id IS NOT NULL"
|
|
||||||
elif source == 'local':
|
|
||||||
count_query += " AND vtiger_id IS NULL"
|
|
||||||
|
|
||||||
if is_active is not None:
|
|
||||||
count_query += " AND is_active = %s"
|
|
||||||
count_params.append(is_active)
|
|
||||||
|
|
||||||
count_result = execute_query(count_query, tuple(count_params), fetchone=True)
|
|
||||||
total = count_result['total'] if count_result else 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"customers": rows or [],
|
|
||||||
"total": total,
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/customers/{customer_id}")
|
|
||||||
async def get_customer(customer_id: int):
|
|
||||||
"""Get single customer by ID with contact count"""
|
|
||||||
# Get customer
|
|
||||||
customer = execute_query(
|
|
||||||
"SELECT * FROM customers WHERE id = %s",
|
|
||||||
(customer_id,),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not customer:
|
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
|
||||||
|
|
||||||
# Get contact count
|
|
||||||
contact_count_result = execute_query(
|
|
||||||
"SELECT COUNT(*) as count FROM contact_companies WHERE customer_id = %s",
|
|
||||||
(customer_id,),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
contact_count = contact_count_result['count'] if contact_count_result else 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
**customer,
|
|
||||||
'contact_count': contact_count
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/customers")
|
|
||||||
async def create_customer(customer: CustomerCreate):
|
|
||||||
"""Create a new customer"""
|
|
||||||
try:
|
|
||||||
customer_id = execute_insert(
|
|
||||||
"""INSERT INTO customers
|
|
||||||
(name, cvr_number, email, phone, address, city, postal_code,
|
|
||||||
country, website, is_active, invoice_email, mobile_phone)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id""",
|
|
||||||
(
|
|
||||||
customer.name,
|
|
||||||
customer.cvr_number,
|
|
||||||
customer.email,
|
|
||||||
customer.phone,
|
|
||||||
customer.address,
|
|
||||||
customer.city,
|
|
||||||
customer.postal_code,
|
|
||||||
customer.country,
|
|
||||||
customer.website,
|
|
||||||
customer.is_active,
|
|
||||||
customer.invoice_email,
|
|
||||||
customer.mobile_phone
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"✅ Created customer {customer_id}: {customer.name}")
|
|
||||||
|
|
||||||
# Fetch and return created customer
|
|
||||||
created = execute_query(
|
|
||||||
"SELECT * FROM customers WHERE id = %s",
|
|
||||||
(customer_id,),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
return created
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Failed to create customer: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/customers/{customer_id}")
|
|
||||||
async def update_customer(customer_id: int, update: CustomerUpdate):
|
|
||||||
"""Update customer information"""
|
|
||||||
# Verify customer exists
|
|
||||||
existing = execute_query(
|
|
||||||
"SELECT id FROM customers WHERE id = %s",
|
|
||||||
(customer_id,),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if not existing:
|
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
|
||||||
|
|
||||||
# Build dynamic UPDATE query
|
|
||||||
updates = []
|
|
||||||
params = []
|
|
||||||
|
|
||||||
update_dict = update.dict(exclude_unset=True)
|
|
||||||
for field, value in update_dict.items():
|
|
||||||
updates.append(f"{field} = %s")
|
|
||||||
params.append(value)
|
|
||||||
|
|
||||||
if not updates:
|
|
||||||
raise HTTPException(status_code=400, detail="No fields to update")
|
|
||||||
|
|
||||||
params.append(customer_id)
|
|
||||||
|
|
||||||
query = f"UPDATE customers SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
|
|
||||||
|
|
||||||
try:
|
|
||||||
execute_update(query, tuple(params))
|
|
||||||
logger.info(f"✅ Updated customer {customer_id}")
|
|
||||||
|
|
||||||
# Fetch and return updated customer
|
|
||||||
updated = execute_query(
|
|
||||||
"SELECT * FROM customers WHERE id = %s",
|
|
||||||
(customer_id,),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
return updated
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Failed to update customer {customer_id}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/customers/{customer_id}/contacts")
|
|
||||||
async def get_customer_contacts(customer_id: int):
|
|
||||||
"""Get all contacts for a specific customer"""
|
|
||||||
rows = execute_query("""
|
|
||||||
SELECT
|
|
||||||
c.*,
|
|
||||||
cc.is_primary,
|
|
||||||
cc.role,
|
|
||||||
cc.notes
|
|
||||||
FROM contacts c
|
|
||||||
JOIN contact_companies cc ON c.id = cc.contact_id
|
|
||||||
WHERE cc.customer_id = %s AND c.is_active = TRUE
|
|
||||||
ORDER BY cc.is_primary DESC, c.first_name, c.last_name
|
|
||||||
""", (customer_id,))
|
|
||||||
|
|
||||||
return rows or []
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/customers/{customer_id}/contacts")
|
|
||||||
async def create_customer_contact(customer_id: int, contact: ContactCreate):
|
|
||||||
"""Create a new contact for a customer"""
|
|
||||||
# Verify customer exists
|
|
||||||
customer = execute_query(
|
|
||||||
"SELECT id FROM customers WHERE id = %s",
|
|
||||||
(customer_id,),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
if not customer:
|
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create contact
|
|
||||||
contact_id = execute_insert(
|
|
||||||
"""INSERT INTO contacts
|
|
||||||
(first_name, last_name, email, phone, mobile, title, department)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id""",
|
|
||||||
(
|
|
||||||
contact.first_name,
|
|
||||||
contact.last_name,
|
|
||||||
contact.email,
|
|
||||||
contact.phone,
|
|
||||||
contact.mobile,
|
|
||||||
contact.title,
|
|
||||||
contact.department
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Link contact to customer
|
|
||||||
execute_insert(
|
|
||||||
"""INSERT INTO contact_companies
|
|
||||||
(contact_id, customer_id, is_primary, role)
|
|
||||||
VALUES (%s, %s, %s, %s)""",
|
|
||||||
(contact_id, customer_id, contact.is_primary, contact.role)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"✅ Created contact {contact_id} for customer {customer_id}")
|
|
||||||
|
|
||||||
# Fetch and return created contact
|
|
||||||
created = execute_query(
|
|
||||||
"SELECT * FROM contacts WHERE id = %s",
|
|
||||||
(contact_id,),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
return created
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Failed to create contact: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cvr/{cvr_number}")
|
|
||||||
async def lookup_cvr(cvr_number: str):
|
|
||||||
"""Lookup company information by CVR number"""
|
|
||||||
cvr_service = get_cvr_service()
|
|
||||||
|
|
||||||
result = await cvr_service.lookup_by_cvr(cvr_number)
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="CVR number not found")
|
|
||||||
|
|
||||||
return result
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
templates = Jinja2Templates(directory="app")
|
|
||||||
|
|
||||||
@router.get("/customers", response_class=HTMLResponse)
|
|
||||||
async def customers_page(request: Request):
|
|
||||||
"""
|
|
||||||
Render the customers list page
|
|
||||||
"""
|
|
||||||
return templates.TemplateResponse("customers/frontend/customers.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/customers/{customer_id}", response_class=HTMLResponse)
|
|
||||||
async def customer_detail_page(request: Request, customer_id: int):
|
|
||||||
"""
|
|
||||||
Render the customer detail page
|
|
||||||
"""
|
|
||||||
return templates.TemplateResponse("customers/frontend/customer_detail.html", {
|
|
||||||
"request": request,
|
|
||||||
"customer_id": customer_id
|
|
||||||
})
|
|
||||||
@ -1,494 +0,0 @@
|
|||||||
{% extends "shared/frontend/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Kunde Detaljer - BMC Hub{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.customer-header {
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
padding: 3rem 2rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customer-avatar-large {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 2rem;
|
|
||||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills-vertical {
|
|
||||||
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills-vertical .nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-radius: 8px 0 0 8px;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills-vertical .nav-link:hover {
|
|
||||||
background: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills-vertical .nav-link.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills-vertical .nav-link i {
|
|
||||||
width: 20px;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-card:hover {
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item {
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-left: 3px solid var(--accent-light);
|
|
||||||
margin-left: 1rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -8px;
|
|
||||||
top: 1.5rem;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 3px solid var(--bg-body);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- Customer Header -->
|
|
||||||
<div class="customer-header">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="customer-avatar-large me-4" id="customerAvatar">?</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="fw-bold mb-2" id="customerName">Loading...</h1>
|
|
||||||
<div class="d-flex gap-3 align-items-center">
|
|
||||||
<span id="customerCity"></span>
|
|
||||||
<span class="badge bg-white bg-opacity-20" id="customerStatus"></span>
|
|
||||||
<span class="badge bg-white bg-opacity-20" id="customerSource"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button class="btn btn-light btn-sm" onclick="editCustomer()">
|
|
||||||
<i class="bi bi-pencil me-2"></i>Rediger
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-light btn-sm" onclick="window.location.href='/customers'">
|
|
||||||
<i class="bi bi-arrow-left me-2"></i>Tilbage
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Layout with Sidebar Navigation -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-3 col-md-4">
|
|
||||||
<!-- Vertical Navigation -->
|
|
||||||
<ul class="nav nav-pills nav-pills-vertical flex-column" role="tablist">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" data-bs-toggle="tab" href="#overview">
|
|
||||||
<i class="bi bi-info-circle"></i>Oversigt
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#contacts">
|
|
||||||
<i class="bi bi-people"></i>Kontakter
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#invoices">
|
|
||||||
<i class="bi bi-receipt"></i>Fakturaer
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#hardware">
|
|
||||||
<i class="bi bi-hdd"></i>Hardware
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#activity">
|
|
||||||
<i class="bi bi-clock-history"></i>Aktivitet
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-9 col-md-8">
|
|
||||||
<!-- Tab Content -->
|
|
||||||
<div class="tab-content">
|
|
||||||
<!-- Overview Tab -->
|
|
||||||
<div class="tab-pane fade show active" id="overview">
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="info-card">
|
|
||||||
<h5 class="fw-bold mb-4">Virksomhedsoplysninger</h5>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">CVR-nummer</span>
|
|
||||||
<span class="info-value" id="cvrNumber">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Adresse</span>
|
|
||||||
<span class="info-value" id="address">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Postnummer & By</span>
|
|
||||||
<span class="info-value" id="postalCity">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Email</span>
|
|
||||||
<span class="info-value" id="email">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Telefon</span>
|
|
||||||
<span class="info-value" id="phone">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Hjemmeside</span>
|
|
||||||
<span class="info-value" id="website">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="info-card">
|
|
||||||
<h5 class="fw-bold mb-4">Økonomiske Oplysninger</h5>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">e-conomic Kundenr.</span>
|
|
||||||
<span class="info-value" id="economicNumber">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Betalingsbetingelser</span>
|
|
||||||
<span class="info-value" id="paymentTerms">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Moms Zone</span>
|
|
||||||
<span class="info-value" id="vatZone">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Valuta</span>
|
|
||||||
<span class="info-value" id="currency">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">EAN-nummer</span>
|
|
||||||
<span class="info-value" id="ean">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Spærret</span>
|
|
||||||
<span class="info-value" id="barred">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="info-card">
|
|
||||||
<h5 class="fw-bold mb-3">Integration</h5>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">vTiger ID</span>
|
|
||||||
<span class="info-value" id="vtigerId">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">vTiger Sidst Synkroniseret</span>
|
|
||||||
<span class="info-value" id="vtigerSyncAt">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">e-conomic Sidst Synkroniseret</span>
|
|
||||||
<span class="info-value" id="economicSyncAt">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Oprettet</span>
|
|
||||||
<span class="info-value" id="createdAt">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contacts Tab -->
|
|
||||||
<div class="tab-pane fade" id="contacts">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h5 class="fw-bold mb-0">Kontaktpersoner</h5>
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="showAddContactModal()">
|
|
||||||
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="row g-4" id="contactsContainer">
|
|
||||||
<div class="col-12 text-center py-5">
|
|
||||||
<div class="spinner-border text-primary"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Invoices Tab -->
|
|
||||||
<div class="tab-pane fade" id="invoices">
|
|
||||||
<h5 class="fw-bold mb-4">Fakturaer</h5>
|
|
||||||
<div class="text-muted text-center py-5">
|
|
||||||
Fakturamodul kommer snart...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hardware Tab -->
|
|
||||||
<div class="tab-pane fade" id="hardware">
|
|
||||||
<h5 class="fw-bold mb-4">Hardware</h5>
|
|
||||||
<div class="text-muted text-center py-5">
|
|
||||||
Hardwaremodul kommer snart...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Activity Tab -->
|
|
||||||
<div class="tab-pane fade" id="activity">
|
|
||||||
<h5 class="fw-bold mb-4">Aktivitet</h5>
|
|
||||||
<div id="activityContainer">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
const customerId = parseInt(window.location.pathname.split('/').pop());
|
|
||||||
let customerData = null;
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
loadCustomer();
|
|
||||||
|
|
||||||
// Load contacts when tab is shown
|
|
||||||
document.querySelector('a[href="#contacts"]').addEventListener('shown.bs.tab', () => {
|
|
||||||
loadContacts();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load activity when tab is shown
|
|
||||||
document.querySelector('a[href="#activity"]').addEventListener('shown.bs.tab', () => {
|
|
||||||
loadActivity();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadCustomer() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/customers/${customerId}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Customer not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
customerData = await response.json();
|
|
||||||
displayCustomer(customerData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load customer:', error);
|
|
||||||
alert('Kunne ikke indlæse kunde');
|
|
||||||
window.location.href = '/customers';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayCustomer(customer) {
|
|
||||||
// Update page title
|
|
||||||
document.title = `${customer.name} - BMC Hub`;
|
|
||||||
|
|
||||||
// Header
|
|
||||||
document.getElementById('customerAvatar').textContent = getInitials(customer.name);
|
|
||||||
document.getElementById('customerName').textContent = customer.name;
|
|
||||||
document.getElementById('customerCity').textContent = customer.city || '';
|
|
||||||
|
|
||||||
const statusBadge = customer.is_active
|
|
||||||
? '<i class="bi bi-check-circle me-1"></i>Aktiv'
|
|
||||||
: '<i class="bi bi-x-circle me-1"></i>Inaktiv';
|
|
||||||
document.getElementById('customerStatus').innerHTML = statusBadge;
|
|
||||||
|
|
||||||
const sourceBadge = customer.vtiger_id
|
|
||||||
? '<i class="bi bi-cloud me-1"></i>vTiger'
|
|
||||||
: '<i class="bi bi-hdd me-1"></i>Lokal';
|
|
||||||
document.getElementById('customerSource').innerHTML = sourceBadge;
|
|
||||||
|
|
||||||
// Company Information
|
|
||||||
document.getElementById('cvrNumber').textContent = customer.cvr_number || '-';
|
|
||||||
document.getElementById('address').textContent = customer.address || '-';
|
|
||||||
document.getElementById('postalCity').textContent = customer.postal_code && customer.city
|
|
||||||
? `${customer.postal_code} ${customer.city}`
|
|
||||||
: customer.city || '-';
|
|
||||||
document.getElementById('email').textContent = customer.email || '-';
|
|
||||||
document.getElementById('phone').textContent = customer.phone || '-';
|
|
||||||
document.getElementById('website').textContent = customer.website || '-';
|
|
||||||
|
|
||||||
// Economic Information
|
|
||||||
document.getElementById('economicNumber').textContent = customer.economic_customer_number || '-';
|
|
||||||
document.getElementById('paymentTerms').textContent = customer.payment_terms_days
|
|
||||||
? `${customer.payment_terms_days} dage netto`
|
|
||||||
: '-';
|
|
||||||
document.getElementById('vatZone').textContent = customer.vat_zone || '-';
|
|
||||||
document.getElementById('currency').textContent = customer.currency_code || 'DKK';
|
|
||||||
document.getElementById('ean').textContent = customer.ean || '-';
|
|
||||||
document.getElementById('barred').innerHTML = customer.barred
|
|
||||||
? '<span class="badge bg-danger">Ja</span>'
|
|
||||||
: '<span class="badge bg-success">Nej</span>';
|
|
||||||
|
|
||||||
// Integration
|
|
||||||
document.getElementById('vtigerId').textContent = customer.vtiger_id || '-';
|
|
||||||
document.getElementById('vtigerSyncAt').textContent = customer.vtiger_last_sync_at
|
|
||||||
? new Date(customer.vtiger_last_sync_at).toLocaleString('da-DK')
|
|
||||||
: '-';
|
|
||||||
document.getElementById('economicSyncAt').textContent = customer.economic_last_sync_at
|
|
||||||
? new Date(customer.economic_last_sync_at).toLocaleString('da-DK')
|
|
||||||
: '-';
|
|
||||||
document.getElementById('createdAt').textContent = new Date(customer.created_at).toLocaleString('da-DK');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadContacts() {
|
|
||||||
const container = document.getElementById('contactsContainer');
|
|
||||||
container.innerHTML = '<div class="col-12 text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
|
|
||||||
const contacts = await response.json();
|
|
||||||
|
|
||||||
if (!contacts || contacts.length === 0) {
|
|
||||||
container.innerHTML = '<div class="col-12 text-center py-5 text-muted">Ingen kontakter endnu</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = contacts.map(contact => `
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="contact-card">
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
|
||||||
<div>
|
|
||||||
<h6 class="fw-bold mb-1">${escapeHtml(contact.name)}</h6>
|
|
||||||
<div class="text-muted small">${contact.title || 'Kontakt'}</div>
|
|
||||||
</div>
|
|
||||||
${contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : ''}
|
|
||||||
</div>
|
|
||||||
<div class="d-flex flex-column gap-2">
|
|
||||||
${contact.email ? `
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<i class="bi bi-envelope me-2 text-muted"></i>
|
|
||||||
<a href="mailto:${contact.email}">${contact.email}</a>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${contact.phone ? `
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<i class="bi bi-telephone me-2 text-muted"></i>
|
|
||||||
<a href="tel:${contact.phone}">${contact.phone}</a>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${contact.mobile ? `
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<i class="bi bi-phone me-2 text-muted"></i>
|
|
||||||
<a href="tel:${contact.mobile}">${contact.mobile}</a>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load contacts:', error);
|
|
||||||
container.innerHTML = '<div class="col-12 text-center py-5 text-danger">Kunne ikke indlæse kontakter</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadActivity() {
|
|
||||||
const container = document.getElementById('activityContainer');
|
|
||||||
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
|
||||||
|
|
||||||
// TODO: Implement activity log API endpoint
|
|
||||||
setTimeout(() => {
|
|
||||||
container.innerHTML = '<div class="text-muted text-center py-5">Ingen aktivitet at vise</div>';
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function editCustomer() {
|
|
||||||
// TODO: Open edit modal with pre-filled data
|
|
||||||
console.log('Edit customer:', customerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAddContactModal() {
|
|
||||||
// TODO: Open add contact modal
|
|
||||||
console.log('Add contact for customer:', customerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitials(name) {
|
|
||||||
if (!name) return '?';
|
|
||||||
const words = name.trim().split(' ');
|
|
||||||
if (words.length === 1) return words[0].substring(0, 2).toUpperCase();
|
|
||||||
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,502 +0,0 @@
|
|||||||
{% extends "shared/frontend/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Kunder - BMC Hub{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.filter-btn {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid rgba(0,0,0,0.1);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.5rem 1.2rem;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn:hover, .filter-btn.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.customer-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn {
|
|
||||||
border: 1px solid rgba(0,0,0,0.1);
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--bg-card);
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn:hover:not(:disabled) {
|
|
||||||
background: var(--accent-light);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Kunder</h2>
|
|
||||||
<p class="text-muted mb-0">Administrer dine kunder</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" id="searchInput" class="header-search" placeholder="Søg kunde, CVR, email...">
|
|
||||||
<button class="btn btn-primary" onclick="showCreateCustomerModal()">
|
|
||||||
<i class="bi bi-plus-lg me-2"></i>Opret Kunde
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4 d-flex gap-2 flex-wrap">
|
|
||||||
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
|
|
||||||
Alle Kunder <span id="countAll" class="ms-1"></span>
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn" data-filter="active" onclick="setFilter('active')">
|
|
||||||
Aktive <span id="countActive" class="ms-1"></span>
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn" data-filter="inactive" onclick="setFilter('inactive')">
|
|
||||||
Inaktive <span id="countInactive" class="ms-1"></span>
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn" data-filter="vtiger" onclick="setFilter('vtiger')">
|
|
||||||
<i class="bi bi-cloud me-1"></i>vTiger
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn" data-filter="local" onclick="setFilter('local')">
|
|
||||||
<i class="bi bi-hdd me-1"></i>Lokal
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card p-4">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Virksomhed</th>
|
|
||||||
<th>Kontakt Info</th>
|
|
||||||
<th>CVR</th>
|
|
||||||
<th>Kilde</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Kontakter</th>
|
|
||||||
<th class="text-end">Handlinger</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="customersTableBody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="7" class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
|
||||||
<div class="text-muted small">
|
|
||||||
Viser <span id="showingStart">0</span>-<span id="showingEnd">0</span> af <span id="totalCount">0</span> kunder
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button class="pagination-btn" id="prevBtn" onclick="previousPage()">
|
|
||||||
<i class="bi bi-chevron-left"></i> Forrige
|
|
||||||
</button>
|
|
||||||
<button class="pagination-btn" id="nextBtn" onclick="nextPage()">
|
|
||||||
Næste <i class="bi bi-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create Customer Modal -->
|
|
||||||
<div class="modal fade" id="createCustomerModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Opret Ny Kunde</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="createCustomerForm">
|
|
||||||
<!-- CVR Lookup Section -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="form-label">CVR-nummer</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" class="form-control" id="cvrInput" placeholder="12345678" maxlength="8">
|
|
||||||
<button class="btn btn-primary" type="button" id="cvrLookupBtn" onclick="lookupCVR()">
|
|
||||||
<i class="bi bi-search me-2"></i>Søg CVR
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Indtast CVR-nummer for automatisk udfyldning</div>
|
|
||||||
<div id="cvrLookupStatus" class="mt-2"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="my-4">
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<label class="form-label">Virksomhedsnavn <span class="text-danger">*</span></label>
|
|
||||||
<input type="text" class="form-control" id="nameInput" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-8">
|
|
||||||
<label class="form-label">Adresse</label>
|
|
||||||
<input type="text" class="form-control" id="addressInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Postnummer</label>
|
|
||||||
<input type="text" class="form-control" id="postalCodeInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">By</label>
|
|
||||||
<input type="text" class="form-control" id="cityInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Email</label>
|
|
||||||
<input type="email" class="form-control" id="emailInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Telefon</label>
|
|
||||||
<input type="text" class="form-control" id="phoneInput">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Hjemmeside</label>
|
|
||||||
<input type="url" class="form-control" id="websiteInput" placeholder="https://">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="isActiveInput" checked>
|
|
||||||
<label class="form-check-label" for="isActiveInput">
|
|
||||||
Aktiv kunde
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</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="createCustomer()">
|
|
||||||
<i class="bi bi-plus-lg me-2"></i>Opret Kunde
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
let currentPage = 0;
|
|
||||||
let pageSize = 20;
|
|
||||||
let currentFilter = 'all';
|
|
||||||
let searchQuery = '';
|
|
||||||
let totalCustomers = 0;
|
|
||||||
|
|
||||||
// Load customers on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
loadCustomers();
|
|
||||||
|
|
||||||
// Search with debounce
|
|
||||||
let searchTimeout;
|
|
||||||
document.getElementById('searchInput').addEventListener('input', (e) => {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
searchQuery = e.target.value;
|
|
||||||
currentPage = 0;
|
|
||||||
loadCustomers();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function setFilter(filter) {
|
|
||||||
currentFilter = filter;
|
|
||||||
currentPage = 0;
|
|
||||||
|
|
||||||
// Update active button
|
|
||||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
});
|
|
||||||
event.target.classList.add('active');
|
|
||||||
|
|
||||||
loadCustomers();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCustomers() {
|
|
||||||
const tbody = document.getElementById('customersTableBody');
|
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build query parameters
|
|
||||||
let params = new URLSearchParams({
|
|
||||||
limit: pageSize,
|
|
||||||
offset: currentPage * pageSize
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchQuery) {
|
|
||||||
params.append('search', searchQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentFilter === 'active') {
|
|
||||||
params.append('is_active', 'true');
|
|
||||||
} else if (currentFilter === 'inactive') {
|
|
||||||
params.append('is_active', 'false');
|
|
||||||
} else if (currentFilter === 'vtiger' || currentFilter === 'local') {
|
|
||||||
params.append('source', currentFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/customers?${params}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
totalCustomers = data.total;
|
|
||||||
displayCustomers(data.customers);
|
|
||||||
updatePagination(data.total);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load customers:', error);
|
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5 text-danger">Kunne ikke indlæse kunder</td></tr>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayCustomers(customers) {
|
|
||||||
const tbody = document.getElementById('customersTableBody');
|
|
||||||
|
|
||||||
if (!customers || customers.length === 0) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5 text-muted">Ingen kunder fundet</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = customers.map(customer => {
|
|
||||||
const initials = getInitials(customer.name);
|
|
||||||
const statusBadge = customer.is_active
|
|
||||||
? '<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>'
|
|
||||||
: '<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
|
|
||||||
|
|
||||||
const sourceBadge = customer.vtiger_id
|
|
||||||
? '<span class="badge bg-primary bg-opacity-10 text-primary"><i class="bi bi-cloud me-1"></i>vTiger</span>'
|
|
||||||
: '<span class="badge bg-secondary bg-opacity-10 text-secondary"><i class="bi bi-hdd me-1"></i>Lokal</span>';
|
|
||||||
|
|
||||||
const contactCount = customer.contact_count || 0;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr style="cursor: pointer;" onclick="viewCustomer(${customer.id})">
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="customer-avatar me-3">${initials}</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">${escapeHtml(customer.name)}</div>
|
|
||||||
<div class="small text-muted">${customer.city || customer.address || '-'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-medium">${customer.email || '-'}</div>
|
|
||||||
<div class="small text-muted">${customer.phone || '-'}</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">${customer.cvr_number || '-'}</td>
|
|
||||||
<td>${sourceBadge}</td>
|
|
||||||
<td>${statusBadge}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark border">
|
|
||||||
<i class="bi bi-person me-1"></i>${contactCount}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); viewCustomer(${customer.id})">
|
|
||||||
<i class="bi bi-eye"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); editCustomer(${customer.id})">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePagination(total) {
|
|
||||||
const start = currentPage * pageSize + 1;
|
|
||||||
const end = Math.min((currentPage + 1) * pageSize, total);
|
|
||||||
|
|
||||||
document.getElementById('showingStart').textContent = total > 0 ? start : 0;
|
|
||||||
document.getElementById('showingEnd').textContent = end;
|
|
||||||
document.getElementById('totalCount').textContent = total;
|
|
||||||
|
|
||||||
// Update buttons
|
|
||||||
document.getElementById('prevBtn').disabled = currentPage === 0;
|
|
||||||
document.getElementById('nextBtn').disabled = end >= total;
|
|
||||||
}
|
|
||||||
|
|
||||||
function previousPage() {
|
|
||||||
if (currentPage > 0) {
|
|
||||||
currentPage--;
|
|
||||||
loadCustomers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextPage() {
|
|
||||||
if ((currentPage + 1) * pageSize < totalCustomers) {
|
|
||||||
currentPage++;
|
|
||||||
loadCustomers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewCustomer(customerId) {
|
|
||||||
window.location.href = `/customers/${customerId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function editCustomer(customerId) {
|
|
||||||
// TODO: Open edit modal
|
|
||||||
console.log('Edit customer:', customerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCreateCustomerModal() {
|
|
||||||
// Reset form
|
|
||||||
document.getElementById('createCustomerForm').reset();
|
|
||||||
document.getElementById('cvrLookupStatus').innerHTML = '';
|
|
||||||
document.getElementById('isActiveInput').checked = true;
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('createCustomerModal'));
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function lookupCVR() {
|
|
||||||
const cvrInput = document.getElementById('cvrInput');
|
|
||||||
const cvr = cvrInput.value.trim();
|
|
||||||
const statusDiv = document.getElementById('cvrLookupStatus');
|
|
||||||
const lookupBtn = document.getElementById('cvrLookupBtn');
|
|
||||||
|
|
||||||
if (!cvr || cvr.length !== 8) {
|
|
||||||
statusDiv.innerHTML = '<div class="alert alert-warning mb-0"><i class="bi bi-exclamation-triangle me-2"></i>Indtast et gyldigt 8-cifret CVR-nummer</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
lookupBtn.disabled = true;
|
|
||||||
lookupBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Søger...';
|
|
||||||
statusDiv.innerHTML = '<div class="alert alert-info mb-0"><i class="bi bi-hourglass-split me-2"></i>Henter virksomhedsoplysninger...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/cvr/${cvr}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('CVR ikke fundet');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Auto-fill form fields
|
|
||||||
document.getElementById('nameInput').value = data.name || '';
|
|
||||||
document.getElementById('addressInput').value = data.address || '';
|
|
||||||
document.getElementById('postalCodeInput').value = data.postal_code || '';
|
|
||||||
document.getElementById('cityInput').value = data.city || '';
|
|
||||||
document.getElementById('phoneInput').value = data.phone || '';
|
|
||||||
document.getElementById('emailInput').value = data.email || '';
|
|
||||||
|
|
||||||
statusDiv.innerHTML = '<div class="alert alert-success mb-0"><i class="bi bi-check-circle me-2"></i>Virksomhedsoplysninger hentet fra CVR-registeret</div>';
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('CVR lookup failed:', error);
|
|
||||||
statusDiv.innerHTML = '<div class="alert alert-danger mb-0"><i class="bi bi-x-circle me-2"></i>Kunne ikke finde virksomhed med CVR-nummer ' + cvr + '</div>';
|
|
||||||
} finally {
|
|
||||||
lookupBtn.disabled = false;
|
|
||||||
lookupBtn.innerHTML = '<i class="bi bi-search me-2"></i>Søg CVR';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createCustomer() {
|
|
||||||
const name = document.getElementById('nameInput').value.trim();
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
alert('Virksomhedsnavn er påkrævet');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const customerData = {
|
|
||||||
name: name,
|
|
||||||
cvr_number: document.getElementById('cvrInput').value.trim() || null,
|
|
||||||
address: document.getElementById('addressInput').value.trim() || null,
|
|
||||||
postal_code: document.getElementById('postalCodeInput').value.trim() || null,
|
|
||||||
city: document.getElementById('cityInput').value.trim() || null,
|
|
||||||
email: document.getElementById('emailInput').value.trim() || null,
|
|
||||||
phone: document.getElementById('phoneInput').value.trim() || null,
|
|
||||||
website: document.getElementById('websiteInput').value.trim() || null,
|
|
||||||
is_active: document.getElementById('isActiveInput').checked
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/customers', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(customerData)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || 'Kunne ikke oprette kunde');
|
|
||||||
}
|
|
||||||
|
|
||||||
const newCustomer = await response.json();
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createCustomerModal'));
|
|
||||||
modal.hide();
|
|
||||||
|
|
||||||
// Reload customer list
|
|
||||||
await loadCustomers();
|
|
||||||
|
|
||||||
// Show success message (optional)
|
|
||||||
alert('Kunde oprettet succesfuldt!');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create customer:', error);
|
|
||||||
alert('Fejl ved oprettelse af kunde: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitials(name) {
|
|
||||||
if (!name) return '?';
|
|
||||||
const words = name.trim().split(' ');
|
|
||||||
if (words.length === 1) return words[0].substring(0, 2).toUpperCase();
|
|
||||||
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from app.core.database import execute_query
|
|
||||||
from typing import Dict, Any, List
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.get("/stats", response_model=Dict[str, Any])
|
|
||||||
async def get_dashboard_stats():
|
|
||||||
"""
|
|
||||||
Get aggregated statistics for the dashboard
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.info("📊 Fetching dashboard stats...")
|
|
||||||
|
|
||||||
# 1. Customer Counts
|
|
||||||
logger.info("Fetching customer count...")
|
|
||||||
customer_res = execute_query("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL", fetchone=True)
|
|
||||||
customer_count = customer_res['count'] if customer_res else 0
|
|
||||||
|
|
||||||
# 2. Contact Counts
|
|
||||||
logger.info("Fetching contact count...")
|
|
||||||
contact_res = execute_query("SELECT COUNT(*) as count FROM contacts", fetchone=True)
|
|
||||||
contact_count = contact_res['count'] if contact_res else 0
|
|
||||||
|
|
||||||
# 3. Vendor Counts
|
|
||||||
logger.info("Fetching vendor count...")
|
|
||||||
vendor_res = execute_query("SELECT COUNT(*) as count FROM vendors", fetchone=True)
|
|
||||||
vendor_count = vendor_res['count'] if vendor_res else 0
|
|
||||||
|
|
||||||
# 4. Recent Customers (Real "Activity")
|
|
||||||
logger.info("Fetching recent customers...")
|
|
||||||
recent_customers = execute_query("""
|
|
||||||
SELECT id, name, created_at, 'customer' as type
|
|
||||||
FROM customers
|
|
||||||
WHERE deleted_at IS NULL
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 5
|
|
||||||
""")
|
|
||||||
|
|
||||||
# 5. Vendor Categories Distribution
|
|
||||||
logger.info("Fetching vendor distribution...")
|
|
||||||
vendor_categories = execute_query("""
|
|
||||||
SELECT category, COUNT(*) as count
|
|
||||||
FROM vendors
|
|
||||||
GROUP BY category
|
|
||||||
""")
|
|
||||||
|
|
||||||
logger.info("✅ Dashboard stats fetched successfully")
|
|
||||||
return {
|
|
||||||
"counts": {
|
|
||||||
"customers": customer_count,
|
|
||||||
"contacts": contact_count,
|
|
||||||
"vendors": vendor_count
|
|
||||||
},
|
|
||||||
"recent_activity": recent_customers or [],
|
|
||||||
"vendor_distribution": vendor_categories or [],
|
|
||||||
"system_status": "online"
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error fetching dashboard stats: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search", response_model=Dict[str, List[Any]])
|
|
||||||
async def global_search(q: str):
|
|
||||||
"""
|
|
||||||
Global search across customers, contacts, and vendors
|
|
||||||
"""
|
|
||||||
if not q or len(q) < 2:
|
|
||||||
return {"customers": [], "contacts": [], "vendors": []}
|
|
||||||
|
|
||||||
search_term = f"%{q}%"
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Search Customers
|
|
||||||
customers = execute_query("""
|
|
||||||
SELECT id, name, email, 'Kunde' as type
|
|
||||||
FROM customers
|
|
||||||
WHERE deleted_at IS NULL AND (
|
|
||||||
name ILIKE %s OR
|
|
||||||
email ILIKE %s OR
|
|
||||||
cvr_number ILIKE %s OR
|
|
||||||
phone ILIKE %s OR
|
|
||||||
mobile_phone ILIKE %s
|
|
||||||
)
|
|
||||||
LIMIT 5
|
|
||||||
""", (search_term, search_term, search_term, search_term, search_term))
|
|
||||||
|
|
||||||
# Search Contacts
|
|
||||||
contacts = execute_query("""
|
|
||||||
SELECT id, first_name || ' ' || last_name as name, email, 'Kontakt' as type
|
|
||||||
FROM contacts
|
|
||||||
WHERE first_name ILIKE %s OR
|
|
||||||
last_name ILIKE %s OR
|
|
||||||
email ILIKE %s OR
|
|
||||||
phone ILIKE %s OR
|
|
||||||
mobile ILIKE %s
|
|
||||||
LIMIT 5
|
|
||||||
""", (search_term, search_term, search_term, search_term, search_term))
|
|
||||||
|
|
||||||
# Search Vendors
|
|
||||||
vendors = execute_query("""
|
|
||||||
SELECT id, name, email, 'Leverandør' as type
|
|
||||||
FROM vendors
|
|
||||||
WHERE is_active = true AND (
|
|
||||||
name ILIKE %s OR
|
|
||||||
email ILIKE %s OR
|
|
||||||
cvr_number ILIKE %s OR
|
|
||||||
phone ILIKE %s
|
|
||||||
)
|
|
||||||
LIMIT 5
|
|
||||||
""", (search_term, search_term, search_term, search_term))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"customers": customers or [],
|
|
||||||
"contacts": contacts or [],
|
|
||||||
"vendors": vendors or []
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error performing global search: {e}", exc_info=True)
|
|
||||||
return {"customers": [], "contacts": [], "vendors": []}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/live-stats", response_model=Dict[str, Any])
|
|
||||||
async def get_live_stats():
|
|
||||||
"""
|
|
||||||
Get live statistics for the three live boxes: Sales, Support, Økonomi
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Sales Stats (placeholder - replace with real data when tables exist)
|
|
||||||
sales_stats = {
|
|
||||||
"active_orders": 0,
|
|
||||||
"monthly_sales": 0,
|
|
||||||
"open_quotes": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Support Stats (placeholder)
|
|
||||||
support_stats = {
|
|
||||||
"open_tickets": 0,
|
|
||||||
"avg_response_time": 0,
|
|
||||||
"today_tickets": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Finance Stats (placeholder)
|
|
||||||
finance_stats = {
|
|
||||||
"unpaid_invoices_count": 0,
|
|
||||||
"unpaid_invoices_amount": 0,
|
|
||||||
"overdue_invoices": 0,
|
|
||||||
"today_payments": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Try to get real customer count as a demo
|
|
||||||
try:
|
|
||||||
customer_count = execute_query("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL", fetchone=True)
|
|
||||||
sales_stats["active_orders"] = customer_count.get('count', 0) if customer_count else 0
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"sales": sales_stats,
|
|
||||||
"support": support_stats,
|
|
||||||
"finance": finance_stats
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error fetching live stats: {e}", exc_info=True)
|
|
||||||
return {
|
|
||||||
"sales": {"active_orders": 0, "monthly_sales": 0, "open_quotes": 0},
|
|
||||||
"support": {"open_tickets": 0, "avg_response_time": 0, "today_tickets": 0},
|
|
||||||
"finance": {"unpaid_invoices_count": 0, "unpaid_invoices_amount": 0, "overdue_invoices": 0, "today_payments": 0}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recent-activity", response_model=List[Dict[str, Any]])
|
|
||||||
async def get_recent_activity():
|
|
||||||
"""
|
|
||||||
Get recent activity across the system for the sidebar
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
activities = []
|
|
||||||
|
|
||||||
# Recent customers
|
|
||||||
recent_customers = execute_query("""
|
|
||||||
SELECT id, name, created_at, 'customer' as activity_type, 'bi-building' as icon, 'primary' as color
|
|
||||||
FROM customers
|
|
||||||
WHERE deleted_at IS NULL
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 3
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Recent contacts
|
|
||||||
recent_contacts = execute_query("""
|
|
||||||
SELECT id, first_name || ' ' || last_name as name, created_at, 'contact' as activity_type, 'bi-person' as icon, 'success' as color
|
|
||||||
FROM contacts
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 3
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Recent vendors
|
|
||||||
recent_vendors = execute_query("""
|
|
||||||
SELECT id, name, created_at, 'vendor' as activity_type, 'bi-shop' as icon, 'info' as color
|
|
||||||
FROM vendors
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 2
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Combine all activities
|
|
||||||
if recent_customers:
|
|
||||||
activities.extend(recent_customers)
|
|
||||||
if recent_contacts:
|
|
||||||
activities.extend(recent_contacts)
|
|
||||||
if recent_vendors:
|
|
||||||
activities.extend(recent_vendors)
|
|
||||||
|
|
||||||
# Sort by created_at and limit
|
|
||||||
activities.sort(key=lambda x: x.get('created_at', ''), reverse=True)
|
|
||||||
|
|
||||||
return activities[:10]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error fetching recent activity: {e}", exc_info=True)
|
|
||||||
return []
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
templates = Jinja2Templates(directory="app")
|
|
||||||
|
|
||||||
@router.get("/dashboard", response_class=HTMLResponse)
|
|
||||||
async def dashboard(request: Request):
|
|
||||||
"""
|
|
||||||
Render the dashboard page
|
|
||||||
"""
|
|
||||||
return templates.TemplateResponse("dashboard/frontend/index.html", {"request": request})
|
|
||||||
@ -1,225 +0,0 @@
|
|||||||
{% extends "shared/frontend/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Dashboard - BMC Hub{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Dashboard</h2>
|
|
||||||
<p class="text-muted mb-0">Velkommen tilbage, Christian</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
|
|
||||||
<input type="text" id="dashboardSearchInput" class="form-control border-start-0 ps-0" placeholder="Søg i alt... (⌘K)" style="max-width: 250px;" role="button">
|
|
||||||
</div>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
|
||||||
<i class="bi bi-plus-lg me-2"></i>Ny Oprettelse
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
|
||||||
<li><a class="dropdown-item" href="/customers"><i class="bi bi-building me-2"></i>Ny Kunde</a></li>
|
|
||||||
<li><a class="dropdown-item" href="/contacts"><i class="bi bi-person me-2"></i>Ny Kontakt</a></li>
|
|
||||||
<li><a class="dropdown-item" href="/vendors"><i class="bi bi-shop me-2"></i>Ny Leverandør</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 1. Live Metrics Cards -->
|
|
||||||
<div class="row g-4 mb-5">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card stat-card p-4 h-100">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<p>Kunder</p>
|
|
||||||
<i class="bi bi-building text-primary" style="color: var(--accent) !important;"></i>
|
|
||||||
</div>
|
|
||||||
<h3 id="customerCount">-</h3>
|
|
||||||
<small class="text-success"><i class="bi bi-check-circle"></i> Aktive i systemet</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card stat-card p-4 h-100">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<p>Kontakter</p>
|
|
||||||
<i class="bi bi-people text-primary" style="color: var(--accent) !important;"></i>
|
|
||||||
</div>
|
|
||||||
<h3 id="contactCount">-</h3>
|
|
||||||
<small class="text-muted">Tilknyttede personer</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card stat-card p-4 h-100">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<p>Leverandører</p>
|
|
||||||
<i class="bi bi-shop text-primary" style="color: var(--accent) !important;"></i>
|
|
||||||
</div>
|
|
||||||
<h3 id="vendorCount">-</h3>
|
|
||||||
<small class="text-muted">Aktive leverandøraftaler</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card stat-card p-4 h-100">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<p>System Status</p>
|
|
||||||
<i class="bi bi-cpu text-primary" style="color: var(--accent) !important;"></i>
|
|
||||||
</div>
|
|
||||||
<h3 id="systemStatus" class="text-success">Online</h3>
|
|
||||||
<small class="text-muted" id="systemVersion">v1.0.0</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4">
|
|
||||||
<!-- 2. Recent Activity (New Customers) -->
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card p-4 h-100">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h5 class="fw-bold mb-0">Seneste Tilføjelser</h5>
|
|
||||||
<a href="/customers" class="btn btn-sm btn-light">Se alle</a>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Navn</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Oprettet</th>
|
|
||||||
<th class="text-end">Handling</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="recentActivityTable">
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="text-center py-4">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 3. Vendor Distribution & Quick Links -->
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card p-4 mb-4">
|
|
||||||
<h5 class="fw-bold mb-4">Leverandør Fordeling</h5>
|
|
||||||
<div id="vendorDistribution">
|
|
||||||
<div class="text-center py-3">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 4. Quick Actions / Shortcuts -->
|
|
||||||
<div class="card p-4">
|
|
||||||
<h5 class="fw-bold mb-3">Genveje</h5>
|
|
||||||
<div class="d-grid gap-2">
|
|
||||||
<a href="/settings" class="btn btn-light text-start p-3 d-flex align-items-center">
|
|
||||||
<div class="bg-white p-2 rounded me-3 shadow-sm">
|
|
||||||
<i class="bi bi-gear text-primary"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Indstillinger</div>
|
|
||||||
<small class="text-muted">Konfigurer systemet</small>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="/vendors" class="btn btn-light text-start p-3 d-flex align-items-center">
|
|
||||||
<div class="bg-white p-2 rounded me-3 shadow-sm">
|
|
||||||
<i class="bi bi-truck text-success"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Leverandører</div>
|
|
||||||
<small class="text-muted">Administrer aftaler</small>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
async function loadDashboardStats() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/dashboard/stats');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Update Counts
|
|
||||||
document.getElementById('customerCount').textContent = data.counts.customers;
|
|
||||||
document.getElementById('contactCount').textContent = data.counts.contacts;
|
|
||||||
document.getElementById('vendorCount').textContent = data.counts.vendors;
|
|
||||||
|
|
||||||
// Update Recent Activity
|
|
||||||
const activityTable = document.getElementById('recentActivityTable');
|
|
||||||
if (data.recent_activity && data.recent_activity.length > 0) {
|
|
||||||
activityTable.innerHTML = data.recent_activity.map(item => `
|
|
||||||
<tr>
|
|
||||||
<td class="fw-bold">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">
|
|
||||||
<i class="bi bi-building text-primary"></i>
|
|
||||||
</div>
|
|
||||||
${item.name}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td><span class="badge bg-primary bg-opacity-10 text-primary">Kunde</span></td>
|
|
||||||
<td class="text-muted">${new Date(item.created_at).toLocaleDateString('da-DK')}</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<a href="/customers/${item.id}" class="btn btn-sm btn-light"><i class="bi bi-arrow-right"></i></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
} else {
|
|
||||||
activityTable.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-4">Ingen nylig aktivitet</td></tr>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Vendor Distribution
|
|
||||||
const vendorDist = document.getElementById('vendorDistribution');
|
|
||||||
if (data.vendor_distribution && data.vendor_distribution.length > 0) {
|
|
||||||
const total = data.counts.vendors;
|
|
||||||
vendorDist.innerHTML = data.vendor_distribution.map(cat => {
|
|
||||||
const percentage = Math.round((cat.count / total) * 100);
|
|
||||||
return `
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="d-flex justify-content-between mb-1">
|
|
||||||
<span class="small fw-bold">${cat.category || 'Ukendt'}</span>
|
|
||||||
<span class="small text-muted">${cat.count}</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 6px;">
|
|
||||||
<div class="progress-bar" role="progressbar" style="width: ${percentage}%" aria-valuenow="${percentage}" aria-valuemin="0" aria-valuemax="100"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
} else {
|
|
||||||
vendorDist.innerHTML = '<p class="text-muted text-center">Ingen leverandørdata</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading dashboard stats:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', loadDashboardStats);
|
|
||||||
|
|
||||||
// Connect dashboard search input to global search modal
|
|
||||||
document.getElementById('dashboardSearchInput').addEventListener('click', () => {
|
|
||||||
const modalEl = document.getElementById('globalSearchModal');
|
|
||||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
|
||||||
modal.show();
|
|
||||||
|
|
||||||
// Focus input when modal opens
|
|
||||||
modalEl.addEventListener('shown.bs.modal', () => {
|
|
||||||
document.getElementById('globalSearchInput').focus();
|
|
||||||
}, { once: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also handle focus (e.g. via tab navigation)
|
|
||||||
document.getElementById('dashboardSearchInput').addEventListener('focus', (e) => {
|
|
||||||
e.target.click();
|
|
||||||
e.target.blur(); // Remove focus from this input so we don't get stuck in a loop or keep cursor here
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,292 +0,0 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from app.core.database import execute_query
|
|
||||||
from typing import List, Optional, Dict, Any
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from datetime import date, datetime
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
# Pydantic Models
|
|
||||||
class Feature(BaseModel):
|
|
||||||
id: Optional[int] = None
|
|
||||||
title: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
version: Optional[str] = None
|
|
||||||
status: str = 'planlagt'
|
|
||||||
priority: int = 50
|
|
||||||
expected_date: Optional[date] = None
|
|
||||||
completed_date: Optional[date] = None
|
|
||||||
created_at: Optional[datetime] = None
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
class FeatureCreate(BaseModel):
|
|
||||||
title: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
version: Optional[str] = None
|
|
||||||
status: str = 'planlagt'
|
|
||||||
priority: int = 50
|
|
||||||
expected_date: Optional[date] = None
|
|
||||||
|
|
||||||
class Idea(BaseModel):
|
|
||||||
id: Optional[int] = None
|
|
||||||
title: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
votes: int = 0
|
|
||||||
created_at: Optional[datetime] = None
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
class IdeaCreate(BaseModel):
|
|
||||||
title: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
|
|
||||||
class Workflow(BaseModel):
|
|
||||||
id: Optional[int] = None
|
|
||||||
title: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
diagram_xml: str
|
|
||||||
thumbnail_url: Optional[str] = None
|
|
||||||
created_at: Optional[datetime] = None
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
class WorkflowCreate(BaseModel):
|
|
||||||
title: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
diagram_xml: str
|
|
||||||
|
|
||||||
|
|
||||||
# Features/Roadmap Endpoints
|
|
||||||
@router.get("/features", response_model=List[Feature])
|
|
||||||
async def get_features(version: Optional[str] = None, status: Optional[str] = None):
|
|
||||||
"""Get all roadmap features with optional filters"""
|
|
||||||
query = "SELECT * FROM dev_features WHERE 1=1"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if version:
|
|
||||||
query += " AND version = %s"
|
|
||||||
params.append(version)
|
|
||||||
if status:
|
|
||||||
query += " AND status = %s"
|
|
||||||
params.append(status)
|
|
||||||
|
|
||||||
query += " ORDER BY priority DESC, expected_date ASC"
|
|
||||||
result = execute_query(query, tuple(params) if params else None)
|
|
||||||
return result or []
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/features/{feature_id}", response_model=Feature)
|
|
||||||
async def get_feature(feature_id: int):
|
|
||||||
"""Get a specific feature"""
|
|
||||||
result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,), fetchone=True)
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Feature not found")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/features", response_model=Feature)
|
|
||||||
async def create_feature(feature: FeatureCreate):
|
|
||||||
"""Create a new roadmap feature"""
|
|
||||||
query = """
|
|
||||||
INSERT INTO dev_features (title, description, version, status, priority, expected_date)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING *
|
|
||||||
"""
|
|
||||||
result = execute_query(query, (
|
|
||||||
feature.title, feature.description, feature.version,
|
|
||||||
feature.status, feature.priority, feature.expected_date
|
|
||||||
), fetchone=True)
|
|
||||||
|
|
||||||
logger.info(f"✅ Created feature: {feature.title}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/features/{feature_id}", response_model=Feature)
|
|
||||||
async def update_feature(feature_id: int, feature: FeatureCreate):
|
|
||||||
"""Update a roadmap feature"""
|
|
||||||
query = """
|
|
||||||
UPDATE dev_features
|
|
||||||
SET title = %s, description = %s, version = %s, status = %s,
|
|
||||||
priority = %s, expected_date = %s
|
|
||||||
WHERE id = %s
|
|
||||||
RETURNING *
|
|
||||||
"""
|
|
||||||
result = execute_query(query, (
|
|
||||||
feature.title, feature.description, feature.version,
|
|
||||||
feature.status, feature.priority, feature.expected_date, feature_id
|
|
||||||
), fetchone=True)
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Feature not found")
|
|
||||||
|
|
||||||
logger.info(f"✅ Updated feature: {feature_id}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/features/{feature_id}")
|
|
||||||
async def delete_feature(feature_id: int):
|
|
||||||
"""Delete a roadmap feature"""
|
|
||||||
result = execute_query("DELETE FROM dev_features WHERE id = %s RETURNING id", (feature_id,), fetchone=True)
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Feature not found")
|
|
||||||
|
|
||||||
logger.info(f"✅ Deleted feature: {feature_id}")
|
|
||||||
return {"message": "Feature deleted successfully"}
|
|
||||||
|
|
||||||
|
|
||||||
# Ideas Endpoints
|
|
||||||
@router.get("/ideas", response_model=List[Idea])
|
|
||||||
async def get_ideas(category: Optional[str] = None):
|
|
||||||
"""Get all ideas with optional category filter"""
|
|
||||||
query = "SELECT * FROM dev_ideas WHERE 1=1"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if category:
|
|
||||||
query += " AND category = %s"
|
|
||||||
params.append(category)
|
|
||||||
|
|
||||||
query += " ORDER BY votes DESC, created_at DESC"
|
|
||||||
result = execute_query(query, tuple(params) if params else None)
|
|
||||||
return result or []
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideas", response_model=Idea)
|
|
||||||
async def create_idea(idea: IdeaCreate):
|
|
||||||
"""Create a new idea"""
|
|
||||||
query = """
|
|
||||||
INSERT INTO dev_ideas (title, description, category)
|
|
||||||
VALUES (%s, %s, %s)
|
|
||||||
RETURNING *
|
|
||||||
"""
|
|
||||||
result = execute_query(query, (idea.title, idea.description, idea.category), fetchone=True)
|
|
||||||
|
|
||||||
logger.info(f"✅ Created idea: {idea.title}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideas/{idea_id}/vote")
|
|
||||||
async def vote_idea(idea_id: int):
|
|
||||||
"""Increment vote count for an idea"""
|
|
||||||
query = """
|
|
||||||
UPDATE dev_ideas
|
|
||||||
SET votes = votes + 1
|
|
||||||
WHERE id = %s
|
|
||||||
RETURNING *
|
|
||||||
"""
|
|
||||||
result = execute_query(query, (idea_id,), fetchone=True)
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Idea not found")
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/ideas/{idea_id}")
|
|
||||||
async def delete_idea(idea_id: int):
|
|
||||||
"""Delete an idea"""
|
|
||||||
result = execute_query("DELETE FROM dev_ideas WHERE id = %s RETURNING id", (idea_id,), fetchone=True)
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Idea not found")
|
|
||||||
|
|
||||||
logger.info(f"✅ Deleted idea: {idea_id}")
|
|
||||||
return {"message": "Idea deleted successfully"}
|
|
||||||
|
|
||||||
|
|
||||||
# Workflows Endpoints
|
|
||||||
@router.get("/workflows", response_model=List[Workflow])
|
|
||||||
async def get_workflows(category: Optional[str] = None):
|
|
||||||
"""Get all workflows with optional category filter"""
|
|
||||||
query = "SELECT * FROM dev_workflows WHERE 1=1"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if category:
|
|
||||||
query += " AND category = %s"
|
|
||||||
params.append(category)
|
|
||||||
|
|
||||||
query += " ORDER BY created_at DESC"
|
|
||||||
result = execute_query(query, tuple(params) if params else None)
|
|
||||||
return result or []
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/workflows/{workflow_id}", response_model=Workflow)
|
|
||||||
async def get_workflow(workflow_id: int):
|
|
||||||
"""Get a specific workflow"""
|
|
||||||
result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,), fetchone=True)
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/workflows", response_model=Workflow)
|
|
||||||
async def create_workflow(workflow: WorkflowCreate):
|
|
||||||
"""Create a new workflow diagram"""
|
|
||||||
query = """
|
|
||||||
INSERT INTO dev_workflows (title, description, category, diagram_xml)
|
|
||||||
VALUES (%s, %s, %s, %s)
|
|
||||||
RETURNING *
|
|
||||||
"""
|
|
||||||
result = execute_query(query, (
|
|
||||||
workflow.title, workflow.description, workflow.category, workflow.diagram_xml
|
|
||||||
), fetchone=True)
|
|
||||||
|
|
||||||
logger.info(f"✅ Created workflow: {workflow.title}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/workflows/{workflow_id}", response_model=Workflow)
|
|
||||||
async def update_workflow(workflow_id: int, workflow: WorkflowCreate):
|
|
||||||
"""Update a workflow diagram"""
|
|
||||||
query = """
|
|
||||||
UPDATE dev_workflows
|
|
||||||
SET title = %s, description = %s, category = %s, diagram_xml = %s
|
|
||||||
WHERE id = %s
|
|
||||||
RETURNING *
|
|
||||||
"""
|
|
||||||
result = execute_query(query, (
|
|
||||||
workflow.title, workflow.description, workflow.category,
|
|
||||||
workflow.diagram_xml, workflow_id
|
|
||||||
), fetchone=True)
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
||||||
|
|
||||||
logger.info(f"✅ Updated workflow: {workflow_id}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/workflows/{workflow_id}")
|
|
||||||
async def delete_workflow(workflow_id: int):
|
|
||||||
"""Delete a workflow"""
|
|
||||||
result = execute_query("DELETE FROM dev_workflows WHERE id = %s RETURNING id", (workflow_id,), fetchone=True)
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
||||||
|
|
||||||
logger.info(f"✅ Deleted workflow: {workflow_id}")
|
|
||||||
return {"message": "Workflow deleted successfully"}
|
|
||||||
|
|
||||||
|
|
||||||
# Stats endpoint
|
|
||||||
@router.get("/stats")
|
|
||||||
async def get_devportal_stats():
|
|
||||||
"""Get DEV Portal statistics"""
|
|
||||||
features_count = execute_query("SELECT COUNT(*) as count FROM dev_features", fetchone=True)
|
|
||||||
ideas_count = execute_query("SELECT COUNT(*) as count FROM dev_ideas", fetchone=True)
|
|
||||||
workflows_count = execute_query("SELECT COUNT(*) as count FROM dev_workflows", fetchone=True)
|
|
||||||
|
|
||||||
features_by_status = execute_query("""
|
|
||||||
SELECT status, COUNT(*) as count
|
|
||||||
FROM dev_features
|
|
||||||
GROUP BY status
|
|
||||||
""")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"features_count": features_count['count'] if features_count else 0,
|
|
||||||
"ideas_count": ideas_count['count'] if ideas_count else 0,
|
|
||||||
"workflows_count": workflows_count['count'] if workflows_count else 0,
|
|
||||||
"features_by_status": features_by_status or []
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
templates = Jinja2Templates(directory="app")
|
|
||||||
|
|
||||||
@router.get("/devportal", response_class=HTMLResponse)
|
|
||||||
async def devportal_dashboard(request: Request):
|
|
||||||
"""Render the DEV Portal dashboard"""
|
|
||||||
return templates.TemplateResponse("devportal/frontend/portal.html", {"request": request})
|
|
||||||
|
|
||||||
@router.get("/devportal/editor", response_class=HTMLResponse)
|
|
||||||
async def workflow_editor(request: Request, id: int = None):
|
|
||||||
"""Render the workflow editor with draw.io integration"""
|
|
||||||
return templates.TemplateResponse("devportal/frontend/editor.html", {
|
|
||||||
"request": request,
|
|
||||||
"workflow_id": id
|
|
||||||
})
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
{% extends "shared/frontend/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Workflow Editor - DEV Portal{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
#diagramContainer {
|
|
||||||
width: 100%;
|
|
||||||
height: calc(100vh - 200px);
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-toolbar {
|
|
||||||
background: var(--bg-card);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mb-4">
|
|
||||||
<a href="/devportal" class="btn btn-light">
|
|
||||||
<i class="bi bi-arrow-left me-2"></i>Tilbage til DEV Portal
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="editor-toolbar">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<input type="text" class="form-control" id="workflowTitle" placeholder="Workflow titel...">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<input type="text" class="form-control" id="workflowDescription" placeholder="Beskrivelse...">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<select class="form-select" id="workflowCategory">
|
|
||||||
<option value="flowchart">Flowchart</option>
|
|
||||||
<option value="process">Proces</option>
|
|
||||||
<option value="system_diagram">System Diagram</option>
|
|
||||||
<option value="other">Andet</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 text-end">
|
|
||||||
<button class="btn btn-success" onclick="saveWorkflow()">
|
|
||||||
<i class="bi bi-save me-2"></i>Gem Workflow
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="diagramContainer">
|
|
||||||
<iframe id="diagramFrame" frameborder="0" style="width: 100%; height: 100%;"></iframe>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
let currentWorkflowId = {{ workflow_id if workflow_id else 'null' }};
|
|
||||||
let diagramXml = null;
|
|
||||||
|
|
||||||
function initEditor() {
|
|
||||||
const iframe = document.getElementById('diagramFrame');
|
|
||||||
|
|
||||||
// Build draw.io embed URL with parameters
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
embed: '1',
|
|
||||||
ui: 'kennedy',
|
|
||||||
spin: '1',
|
|
||||||
proto: 'json',
|
|
||||||
configure: '1',
|
|
||||||
noSaveBtn: '1',
|
|
||||||
noExitBtn: '1',
|
|
||||||
libraries: '1',
|
|
||||||
saveAndExit: '0'
|
|
||||||
});
|
|
||||||
|
|
||||||
iframe.src = `https://embed.diagrams.net/?${params.toString()}`;
|
|
||||||
|
|
||||||
// Listen for messages from draw.io
|
|
||||||
window.addEventListener('message', function(evt) {
|
|
||||||
if (evt.data.length > 0) {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(evt.data);
|
|
||||||
|
|
||||||
// When editor is ready
|
|
||||||
if (msg.event === 'init') {
|
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({
|
|
||||||
action: 'load',
|
|
||||||
autosave: 1,
|
|
||||||
xml: diagramXml || '' // Load existing diagram if editing
|
|
||||||
}), '*');
|
|
||||||
}
|
|
||||||
|
|
||||||
// When diagram is exported
|
|
||||||
if (msg.event === 'export') {
|
|
||||||
diagramXml = msg.xml;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-save on every change
|
|
||||||
if (msg.event === 'autosave') {
|
|
||||||
diagramXml = msg.xml;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request export when saving
|
|
||||||
if (msg.event === 'save') {
|
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({
|
|
||||||
action: 'export',
|
|
||||||
format: 'xml'
|
|
||||||
}), '*');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore non-JSON messages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadWorkflow() {
|
|
||||||
if (!currentWorkflowId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/devportal/workflows/${currentWorkflowId}`);
|
|
||||||
const workflow = await response.json();
|
|
||||||
|
|
||||||
document.getElementById('workflowTitle').value = workflow.title;
|
|
||||||
document.getElementById('workflowDescription').value = workflow.description || '';
|
|
||||||
document.getElementById('workflowCategory').value = workflow.category || 'flowchart';
|
|
||||||
diagramXml = workflow.diagram_xml;
|
|
||||||
|
|
||||||
// Reinitialize editor with loaded data
|
|
||||||
initEditor();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading workflow:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveWorkflow() {
|
|
||||||
const title = document.getElementById('workflowTitle').value;
|
|
||||||
const description = document.getElementById('workflowDescription').value;
|
|
||||||
const category = document.getElementById('workflowCategory').value;
|
|
||||||
|
|
||||||
if (!title) {
|
|
||||||
alert('Indtast venligst en titel');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request export from draw.io
|
|
||||||
const iframe = document.getElementById('diagramFrame');
|
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({
|
|
||||||
action: 'export',
|
|
||||||
format: 'xml'
|
|
||||||
}), '*');
|
|
||||||
|
|
||||||
// Wait a bit for export to complete
|
|
||||||
setTimeout(async () => {
|
|
||||||
if (!diagramXml) {
|
|
||||||
alert('Kunne ikke eksportere diagram. Prøv igen.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workflow = {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
category,
|
|
||||||
diagram_xml: diagramXml
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
let response;
|
|
||||||
if (currentWorkflowId) {
|
|
||||||
// Update existing
|
|
||||||
response = await fetch(`/api/v1/devportal/workflows/${currentWorkflowId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(workflow)
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Create new
|
|
||||||
response = await fetch('/api/v1/devportal/workflows', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(workflow)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const result = await response.json();
|
|
||||||
alert('Workflow gemt!');
|
|
||||||
window.location.href = '/devportal';
|
|
||||||
} else {
|
|
||||||
alert('Fejl ved gemning af workflow');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving workflow:', error);
|
|
||||||
alert('Fejl ved gemning af workflow');
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on load
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
if (currentWorkflowId) {
|
|
||||||
loadWorkflow();
|
|
||||||
} else {
|
|
||||||
initEditor();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,621 +0,0 @@
|
|||||||
{% extends "shared/frontend/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}DEV Portal - BMC Hub{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.nav-pills .nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-pills .nav-link.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card {
|
|
||||||
border-left: 4px solid;
|
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-planlagt { border-color: #6c757d; }
|
|
||||||
.status-i-gang { border-color: #0d6efd; }
|
|
||||||
.status-færdig { border-color: #198754; }
|
|
||||||
.status-sat-på-pause { border-color: #ffc107; }
|
|
||||||
|
|
||||||
.idea-card {
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.idea-card:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vote-button {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vote-button:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
color: var(--accent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-thumbnail {
|
|
||||||
width: 100%;
|
|
||||||
height: 200px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1"><i class="bi bi-code-square me-2"></i>DEV Portal</h2>
|
|
||||||
<p class="text-muted mb-0">Roadmap, idéer og workflow dokumentation</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2" id="actionButtons">
|
|
||||||
<!-- Dynamic buttons based on active tab -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats Cards -->
|
|
||||||
<div class="row g-4 mb-4" id="statsCards">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-muted mb-1">Features</p>
|
|
||||||
<h3 class="mb-0" id="featuresCount">-</h3>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-flag text-primary" style="font-size: 2rem;"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-muted mb-1">Idéer</p>
|
|
||||||
<h3 class="mb-0" id="ideasCount">-</h3>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-lightbulb text-warning" style="font-size: 2rem;"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-muted mb-1">Workflows</p>
|
|
||||||
<h3 class="mb-0" id="workflowsCount">-</h3>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-diagram-3 text-success" style="font-size: 2rem;"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-muted mb-1">I Gang</p>
|
|
||||||
<h3 class="mb-0" id="inProgressCount">-</h3>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-gear text-info" style="font-size: 2rem;"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navigation Tabs -->
|
|
||||||
<ul class="nav nav-pills mb-4" role="tablist">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" data-bs-toggle="pill" href="#roadmap">
|
|
||||||
<i class="bi bi-calendar3 me-2"></i>Roadmap
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" data-bs-toggle="pill" href="#ideas">
|
|
||||||
<i class="bi bi-lightbulb me-2"></i>Idéer & Brainstorm
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" data-bs-toggle="pill" href="#workflows">
|
|
||||||
<i class="bi bi-diagram-3 me-2"></i>Workflows
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<!-- Tab Content -->
|
|
||||||
<div class="tab-content">
|
|
||||||
<!-- Roadmap Tab -->
|
|
||||||
<div class="tab-pane fade show active" id="roadmap">
|
|
||||||
<!-- Version Filter -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="btn-group" role="group">
|
|
||||||
<button type="button" class="btn btn-outline-primary active" onclick="filterByVersion(null)">Alle</button>
|
|
||||||
<button type="button" class="btn btn-outline-primary" onclick="filterByVersion('V1')">V1</button>
|
|
||||||
<button type="button" class="btn btn-outline-primary" onclick="filterByVersion('V2')">V2</button>
|
|
||||||
<button type="button" class="btn btn-outline-primary" onclick="filterByVersion('V3')">V3</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Kanban Board -->
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-3">
|
|
||||||
<h6 class="fw-bold mb-3 text-secondary">📋 Planlagt</h6>
|
|
||||||
<div id="planlagt-features" class="feature-column"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-3">
|
|
||||||
<h6 class="fw-bold mb-3 text-primary">⚙️ I Gang</h6>
|
|
||||||
<div id="i-gang-features" class="feature-column"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-3">
|
|
||||||
<h6 class="fw-bold mb-3 text-success">✅ Færdig</h6>
|
|
||||||
<div id="færdig-features" class="feature-column"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-3">
|
|
||||||
<h6 class="fw-bold mb-3 text-warning">⏸️ På Pause</h6>
|
|
||||||
<div id="sat-på-pause-features" class="feature-column"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ideas Tab -->
|
|
||||||
<div class="tab-pane fade" id="ideas">
|
|
||||||
<div class="row g-4" id="ideasGrid">
|
|
||||||
<!-- Dynamic ideas cards -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Workflows Tab -->
|
|
||||||
<div class="tab-pane fade" id="workflows">
|
|
||||||
<div class="row g-4" id="workflowsGrid">
|
|
||||||
<!-- Dynamic workflow cards -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create Feature Modal -->
|
|
||||||
<div class="modal fade" id="createFeatureModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Ny Feature</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="createFeatureForm">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Titel *</label>
|
|
||||||
<input type="text" class="form-control" id="featureTitle" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Beskrivelse</label>
|
|
||||||
<textarea class="form-control" id="featureDescription" rows="3"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label">Version</label>
|
|
||||||
<select class="form-select" id="featureVersion">
|
|
||||||
<option value="">Vælg version</option>
|
|
||||||
<option value="V1">V1</option>
|
|
||||||
<option value="V2">V2</option>
|
|
||||||
<option value="V3">V3</option>
|
|
||||||
<option value="V4">V4</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label">Status</label>
|
|
||||||
<select class="form-select" id="featureStatus">
|
|
||||||
<option value="planlagt">Planlagt</option>
|
|
||||||
<option value="i gang">I Gang</option>
|
|
||||||
<option value="færdig">Færdig</option>
|
|
||||||
<option value="sat på pause">På Pause</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label">Prioritet (0-100)</label>
|
|
||||||
<input type="number" class="form-control" id="featurePriority" value="50" min="0" max="100">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label">Forventet Dato</label>
|
|
||||||
<input type="date" class="form-control" id="featureDate">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</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="createFeature()">Opret Feature</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create Idea Modal -->
|
|
||||||
<div class="modal fade" id="createIdeaModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Ny Idé</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="createIdeaForm">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Titel *</label>
|
|
||||||
<input type="text" class="form-control" id="ideaTitle" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Beskrivelse</label>
|
|
||||||
<textarea class="form-control" id="ideaDescription" rows="3"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Kategori</label>
|
|
||||||
<select class="form-select" id="ideaCategory">
|
|
||||||
<option value="feature">Feature</option>
|
|
||||||
<option value="improvement">Forbedring</option>
|
|
||||||
<option value="bugfix">Bugfix</option>
|
|
||||||
<option value="research">Research</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</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="createIdea()">Opret Idé</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
let allFeatures = [];
|
|
||||||
let currentVersionFilter = null;
|
|
||||||
|
|
||||||
// Helper functions to open modals
|
|
||||||
function openCreateFeatureModal() {
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('createFeatureModal'));
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateIdeaModal() {
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('createIdeaModal'));
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStats() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/devportal/stats');
|
|
||||||
if (!response.ok) throw new Error('Kunne ikke hente statistik');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
document.getElementById('featuresCount').textContent = data.features_count;
|
|
||||||
document.getElementById('ideasCount').textContent = data.ideas_count;
|
|
||||||
document.getElementById('workflowsCount').textContent = data.workflows_count;
|
|
||||||
|
|
||||||
const inProgress = data.features_by_status.find(s => s.status === 'i gang');
|
|
||||||
document.getElementById('inProgressCount').textContent = inProgress ? inProgress.count : 0;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading stats:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFeatures() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/devportal/features');
|
|
||||||
if (!response.ok) throw new Error('Kunne ikke hente features');
|
|
||||||
allFeatures = await response.json();
|
|
||||||
console.log(`📊 Loaded ${allFeatures.length} features`);
|
|
||||||
displayFeatures();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading features:', error);
|
|
||||||
alert('Fejl ved indlæsning af features: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayFeatures() {
|
|
||||||
const features = currentVersionFilter
|
|
||||||
? allFeatures.filter(f => f.version === currentVersionFilter)
|
|
||||||
: allFeatures;
|
|
||||||
|
|
||||||
console.log(`🎯 Displaying ${features.length} features (filter: ${currentVersionFilter || 'none'})`);
|
|
||||||
|
|
||||||
// Clear columns
|
|
||||||
['planlagt', 'i gang', 'færdig', 'sat på pause'].forEach(status => {
|
|
||||||
const column = document.getElementById(`${status}-features`);
|
|
||||||
if (column) column.innerHTML = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
features.forEach(feature => {
|
|
||||||
const column = document.getElementById(`${feature.status}-features`);
|
|
||||||
if (!column) return;
|
|
||||||
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = `card feature-card status-${feature.status} p-3 mb-2`;
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
|
||||||
<h6 class="fw-bold mb-0">${feature.title}</h6>
|
|
||||||
<div>
|
|
||||||
${feature.version ? `<span class="badge bg-secondary me-1">${feature.version}</span>` : ''}
|
|
||||||
<button class="btn btn-sm btn-link text-danger p-0" onclick="deleteFeature(${feature.id})" title="Slet">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${feature.description ? `<p class="small text-muted mb-2">${feature.description}</p>` : ''}
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<small class="text-muted">Prioritet: ${feature.priority}</small>
|
|
||||||
${feature.expected_date ? `<small class="text-muted">${new Date(feature.expected_date).toLocaleDateString('da-DK')}</small>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
column.appendChild(card);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterByVersion(version) {
|
|
||||||
currentVersionFilter = version;
|
|
||||||
|
|
||||||
// Update active button
|
|
||||||
document.querySelectorAll('.btn-group .btn').forEach(btn => btn.classList.remove('active'));
|
|
||||||
event.target.classList.add('active');
|
|
||||||
|
|
||||||
displayFeatures();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadIdeas() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/devportal/ideas');
|
|
||||||
if (!response.ok) throw new Error('Kunne ikke hente idéer');
|
|
||||||
const ideas = await response.json();
|
|
||||||
|
|
||||||
const grid = document.getElementById('ideasGrid');
|
|
||||||
grid.innerHTML = ideas.map(idea => `
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card idea-card p-3 h-100">
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
|
||||||
<h6 class="fw-bold">${idea.title}</h6>
|
|
||||||
<span class="badge bg-primary">${idea.category || 'general'}</span>
|
|
||||||
</div>
|
|
||||||
${idea.description ? `<p class="text-muted small mb-3">${idea.description}</p>` : ''}
|
|
||||||
<div class="mt-auto d-flex justify-content-between align-items-center">
|
|
||||||
<div class="vote-button" onclick="voteIdea(${idea.id})">
|
|
||||||
<i class="bi bi-hand-thumbs-up me-1"></i>
|
|
||||||
<span>${idea.votes}</span>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteIdea(${idea.id})">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading ideas:', error);
|
|
||||||
alert('Fejl ved indlæsning af idéer: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadWorkflows() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/devportal/workflows');
|
|
||||||
if (!response.ok) throw new Error('Kunne ikke hente workflows');
|
|
||||||
const workflows = await response.json();
|
|
||||||
|
|
||||||
const grid = document.getElementById('workflowsGrid');
|
|
||||||
grid.innerHTML = workflows.map(wf => `
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="workflow-thumbnail mb-3 d-flex align-items-center justify-content-center">
|
|
||||||
<i class="bi bi-diagram-3" style="font-size: 3rem; opacity: 0.3;"></i>
|
|
||||||
</div>
|
|
||||||
<h6 class="fw-bold">${wf.title}</h6>
|
|
||||||
${wf.description ? `<p class="text-muted small mb-3">${wf.description}</p>` : ''}
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<a href="/devportal/editor?id=${wf.id}" class="btn btn-sm btn-primary flex-grow-1">
|
|
||||||
<i class="bi bi-pencil me-1"></i>Rediger
|
|
||||||
</a>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteWorkflow(${wf.id})">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading workflows:', error);
|
|
||||||
alert('Fejl ved indlæsning af workflows: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createFeature() {
|
|
||||||
const feature = {
|
|
||||||
title: document.getElementById('featureTitle').value,
|
|
||||||
description: document.getElementById('featureDescription').value,
|
|
||||||
version: document.getElementById('featureVersion').value,
|
|
||||||
status: document.getElementById('featureStatus').value,
|
|
||||||
priority: parseInt(document.getElementById('featurePriority').value),
|
|
||||||
expected_date: document.getElementById('featureDate').value || null
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!feature.title) {
|
|
||||||
alert('Titel er påkrævet');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/devportal/features', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(feature)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Kunne ikke oprette feature');
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createFeatureModal'));
|
|
||||||
if (modal) modal.hide();
|
|
||||||
document.getElementById('createFeatureForm').reset();
|
|
||||||
await loadFeatures();
|
|
||||||
await loadStats();
|
|
||||||
console.log('✅ Feature created successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating feature:', error);
|
|
||||||
alert('Fejl ved oprettelse af feature: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createIdea() {
|
|
||||||
const idea = {
|
|
||||||
title: document.getElementById('ideaTitle').value,
|
|
||||||
description: document.getElementById('ideaDescription').value,
|
|
||||||
category: document.getElementById('ideaCategory').value
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!idea.title) {
|
|
||||||
alert('Titel er påkrævet');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/devportal/ideas', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(idea)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Kunne ikke oprette idé');
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createIdeaModal'));
|
|
||||||
if (modal) modal.hide();
|
|
||||||
document.getElementById('createIdeaForm').reset();
|
|
||||||
await loadIdeas();
|
|
||||||
await loadStats();
|
|
||||||
console.log('✅ Idea created successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating idea:', error);
|
|
||||||
alert('Fejl ved oprettelse af idé: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function voteIdea(id) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/devportal/ideas/${id}/vote`, { method: 'POST' });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Kunne ikke stemme');
|
|
||||||
}
|
|
||||||
await loadIdeas();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error voting:', error);
|
|
||||||
alert('Fejl ved stemning: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteIdea(id) {
|
|
||||||
if (!confirm('Er du sikker på at du vil slette denne idé?')) return;
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/devportal/ideas/${id}`, { method: 'DELETE' });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Kunne ikke slette idé');
|
|
||||||
}
|
|
||||||
await loadIdeas();
|
|
||||||
await loadStats();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting idea:', error);
|
|
||||||
alert('Fejl ved sletning: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteFeature(id) {
|
|
||||||
if (!confirm('Er du sikker på at du vil slette denne feature?')) return;
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/devportal/features/${id}`, { method: 'DELETE' });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Kunne ikke slette feature');
|
|
||||||
}
|
|
||||||
await loadFeatures();
|
|
||||||
await loadStats();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting feature:', error);
|
|
||||||
alert('Fejl ved sletning: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteWorkflow(id) {
|
|
||||||
if (!confirm('Er du sikker på at du vil slette denne workflow?')) return;
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/devportal/workflows/${id}`, { method: 'DELETE' });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Kunne ikke slette workflow');
|
|
||||||
}
|
|
||||||
await loadWorkflows();
|
|
||||||
await loadStats();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting workflow:', error);
|
|
||||||
alert('Fejl ved sletning: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab change handling
|
|
||||||
document.querySelectorAll('a[data-bs-toggle="pill"]').forEach(tab => {
|
|
||||||
tab.addEventListener('shown.bs.tab', (e) => {
|
|
||||||
const target = e.target.getAttribute('href');
|
|
||||||
const buttons = document.getElementById('actionButtons');
|
|
||||||
|
|
||||||
if (target === '#roadmap') {
|
|
||||||
buttons.innerHTML = '<button class="btn btn-primary" onclick="openCreateFeatureModal()"><i class="bi bi-plus-lg me-2"></i>Ny Feature</button>';
|
|
||||||
} else if (target === '#ideas') {
|
|
||||||
buttons.innerHTML = '<button class="btn btn-primary" onclick="openCreateIdeaModal()"><i class="bi bi-plus-lg me-2"></i>Ny Idé</button>';
|
|
||||||
} else if (target === '#workflows') {
|
|
||||||
buttons.innerHTML = '<a href="/devportal/editor" class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Ny Workflow</a>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial load
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
console.log('DEV Portal loaded - functions available:', {
|
|
||||||
openCreateFeatureModal: typeof openCreateFeatureModal,
|
|
||||||
openCreateIdeaModal: typeof openCreateIdeaModal,
|
|
||||||
createFeature: typeof createFeature,
|
|
||||||
createIdea: typeof createIdea
|
|
||||||
});
|
|
||||||
|
|
||||||
loadStats();
|
|
||||||
loadFeatures();
|
|
||||||
loadIdeas();
|
|
||||||
loadWorkflows();
|
|
||||||
|
|
||||||
// Set initial button
|
|
||||||
document.getElementById('actionButtons').innerHTML = '<button class="btn btn-primary" onclick="openCreateFeatureModal()"><i class="bi bi-plus-lg me-2"></i>Ny Feature</button>';
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -3,11 +3,10 @@ Pydantic Models and Schemas
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
# Customer Schemas
|
|
||||||
class CustomerBase(BaseModel):
|
class CustomerBase(BaseModel):
|
||||||
"""Base customer schema"""
|
"""Base customer schema"""
|
||||||
name: str
|
name: str
|
||||||
@ -16,30 +15,9 @@ class CustomerBase(BaseModel):
|
|||||||
address: Optional[str] = None
|
address: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class CustomerCreate(BaseModel):
|
class CustomerCreate(CustomerBase):
|
||||||
"""Schema for creating a customer"""
|
"""Schema for creating a customer"""
|
||||||
name: str
|
pass
|
||||||
cvr_number: Optional[str] = None
|
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
address: Optional[str] = None
|
|
||||||
postal_code: Optional[str] = None
|
|
||||||
city: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerUpdate(BaseModel):
|
|
||||||
"""Schema for updating a customer"""
|
|
||||||
name: Optional[str] = None
|
|
||||||
cvr_number: Optional[str] = None
|
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
address: Optional[str] = None
|
|
||||||
postal_code: Optional[str] = None
|
|
||||||
city: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Customer(CustomerBase):
|
class Customer(CustomerBase):
|
||||||
@ -52,70 +30,6 @@ class Customer(CustomerBase):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# Contact Schemas
|
|
||||||
class ContactBase(BaseModel):
|
|
||||||
"""Base contact schema"""
|
|
||||||
first_name: str
|
|
||||||
last_name: str
|
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
mobile: Optional[str] = None
|
|
||||||
title: Optional[str] = None
|
|
||||||
department: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ContactCreate(ContactBase):
|
|
||||||
"""Schema for creating a contact"""
|
|
||||||
company_ids: List[int] = [] # List of customer IDs to link to
|
|
||||||
is_primary: bool = False # Whether this is the primary contact for first company
|
|
||||||
role: Optional[str] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class ContactUpdate(BaseModel):
|
|
||||||
"""Schema for updating a contact"""
|
|
||||||
first_name: Optional[str] = None
|
|
||||||
last_name: Optional[str] = None
|
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
mobile: Optional[str] = None
|
|
||||||
title: Optional[str] = None
|
|
||||||
department: Optional[str] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ContactCompanyLink(BaseModel):
|
|
||||||
"""Schema for linking/unlinking a contact to a company"""
|
|
||||||
customer_id: int
|
|
||||||
is_primary: bool = False
|
|
||||||
role: Optional[str] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyInfo(BaseModel):
|
|
||||||
"""Schema for company information in contact context"""
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
is_primary: bool
|
|
||||||
role: Optional[str] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Contact(ContactBase):
|
|
||||||
"""Full contact schema"""
|
|
||||||
id: int
|
|
||||||
is_active: bool
|
|
||||||
vtiger_id: Optional[str] = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
companies: List[CompanyInfo] = [] # List of linked companies
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
# Hardware Schemas
|
|
||||||
class HardwareBase(BaseModel):
|
class HardwareBase(BaseModel):
|
||||||
"""Base hardware schema"""
|
"""Base hardware schema"""
|
||||||
serial_number: str
|
serial_number: str
|
||||||
@ -135,69 +49,3 @@ class Hardware(HardwareBase):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# Vendor Schemas
|
|
||||||
class VendorBase(BaseModel):
|
|
||||||
"""Base vendor schema"""
|
|
||||||
name: str
|
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class VendorCreate(BaseModel):
|
|
||||||
"""Schema for creating a vendor"""
|
|
||||||
name: str
|
|
||||||
cvr_number: Optional[str] = None
|
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
address: Optional[str] = None
|
|
||||||
postal_code: Optional[str] = None
|
|
||||||
city: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
domain: Optional[str] = None
|
|
||||||
email_pattern: Optional[str] = None
|
|
||||||
category: str = 'general'
|
|
||||||
priority: int = 50
|
|
||||||
notes: Optional[str] = None
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class VendorUpdate(BaseModel):
|
|
||||||
"""Schema for updating a vendor"""
|
|
||||||
name: Optional[str] = None
|
|
||||||
cvr_number: Optional[str] = None
|
|
||||||
email: Optional[str] = None
|
|
||||||
phone: Optional[str] = None
|
|
||||||
address: Optional[str] = None
|
|
||||||
postal_code: Optional[str] = None
|
|
||||||
city: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
domain: Optional[str] = None
|
|
||||||
email_pattern: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
priority: Optional[int] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Vendor(VendorBase):
|
|
||||||
"""Full vendor schema"""
|
|
||||||
id: int
|
|
||||||
cvr_number: Optional[str] = None
|
|
||||||
address: Optional[str] = None
|
|
||||||
postal_code: Optional[str] = None
|
|
||||||
city: Optional[str] = None
|
|
||||||
country: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
domain: Optional[str] = None
|
|
||||||
category: str
|
|
||||||
priority: int
|
|
||||||
notes: Optional[str] = None
|
|
||||||
is_active: bool
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|||||||
1
app/routers/__init__.py
Normal file
1
app/routers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Routers package"""
|
||||||
@ -4,13 +4,9 @@ API endpoints for billing operations
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from . import supplier_invoices
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Include supplier invoices router
|
|
||||||
router.include_router(supplier_invoices.router, prefix="", tags=["Supplier Invoices"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/billing/invoices")
|
@router.get("/billing/invoices")
|
||||||
async def list_invoices():
|
async def list_invoices():
|
||||||
47
app/routers/customers.py
Normal file
47
app/routers/customers.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"""
|
||||||
|
Customers Router
|
||||||
|
API endpoints for customer management
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.models.schemas import Customer, CustomerCreate
|
||||||
|
from app.core.database import execute_query
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers", response_model=List[Customer])
|
||||||
|
async def list_customers():
|
||||||
|
"""List all customers"""
|
||||||
|
query = "SELECT * FROM customers ORDER BY created_at DESC"
|
||||||
|
customers = execute_query(query)
|
||||||
|
return customers
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers/{customer_id}", response_model=Customer)
|
||||||
|
async def get_customer(customer_id: int):
|
||||||
|
"""Get a specific customer"""
|
||||||
|
query = "SELECT * FROM customers WHERE id = %s"
|
||||||
|
customers = execute_query(query, (customer_id,))
|
||||||
|
|
||||||
|
if not customers:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
|
||||||
|
return customers[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/customers", response_model=Customer)
|
||||||
|
async def create_customer(customer: CustomerCreate):
|
||||||
|
"""Create a new customer"""
|
||||||
|
query = """
|
||||||
|
INSERT INTO customers (name, email, phone, address)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
result = execute_query(
|
||||||
|
query,
|
||||||
|
(customer.name, customer.email, customer.phone, customer.address)
|
||||||
|
)
|
||||||
|
return result[0]
|
||||||
@ -1,136 +0,0 @@
|
|||||||
"""
|
|
||||||
CVR.dk API service for looking up Danish company information
|
|
||||||
Free public API - no authentication required
|
|
||||||
Adapted from OmniSync for BMC Hub
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import aiohttp
|
|
||||||
import logging
|
|
||||||
from typing import Optional, Dict
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CVRService:
|
|
||||||
"""Service for CVR.dk API lookups"""
|
|
||||||
|
|
||||||
BASE_URL = "https://cvrapi.dk/api"
|
|
||||||
|
|
||||||
async def lookup_by_name(self, company_name: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Lookup company by name using CVR.dk API
|
|
||||||
|
|
||||||
Args:
|
|
||||||
company_name: Company name to search for
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Company data dict or None if not found
|
|
||||||
"""
|
|
||||||
if not company_name or len(company_name) < 3:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Clean company name
|
|
||||||
clean_name = company_name.strip()
|
|
||||||
|
|
||||||
try:
|
|
||||||
params = {
|
|
||||||
'search': clean_name,
|
|
||||||
'country': 'dk'
|
|
||||||
}
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(
|
|
||||||
f"{self.BASE_URL}",
|
|
||||||
params=params,
|
|
||||||
timeout=aiohttp.ClientTimeout(total=10)
|
|
||||||
) as response:
|
|
||||||
if response.status == 200:
|
|
||||||
data = await response.json()
|
|
||||||
|
|
||||||
if data and 'vat' in data:
|
|
||||||
logger.info(f"✅ Found CVR {data['vat']} for '{company_name}'")
|
|
||||||
return {
|
|
||||||
'cvr': data.get('vat'),
|
|
||||||
'name': data.get('name'),
|
|
||||||
'address': data.get('address'),
|
|
||||||
'city': data.get('city'),
|
|
||||||
'zipcode': data.get('zipcode'),
|
|
||||||
'country': data.get('country'),
|
|
||||||
'phone': data.get('phone'),
|
|
||||||
'email': data.get('email'),
|
|
||||||
'vat': data.get('vat'),
|
|
||||||
'status': data.get('status')
|
|
||||||
}
|
|
||||||
|
|
||||||
elif response.status == 404:
|
|
||||||
logger.warning(f"⚠️ No CVR found for '{company_name}'")
|
|
||||||
return None
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.error(f"❌ CVR API error {response.status} for '{company_name}'")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.error(f"⏱️ CVR API timeout for '{company_name}'")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ CVR lookup error for '{company_name}': {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def lookup_by_cvr(self, cvr_number: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Lookup company by CVR number
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cvr_number: CVR number (8 digits)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Company data dict or None if not found
|
|
||||||
"""
|
|
||||||
if not cvr_number:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Extract only digits
|
|
||||||
cvr_clean = ''.join(filter(str.isdigit, str(cvr_number)))
|
|
||||||
|
|
||||||
if len(cvr_clean) != 8:
|
|
||||||
logger.warning(f"⚠️ Invalid CVR number format: {cvr_number}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(
|
|
||||||
f"{self.BASE_URL}",
|
|
||||||
params={'vat': cvr_clean, 'country': 'dk'},
|
|
||||||
timeout=aiohttp.ClientTimeout(total=10)
|
|
||||||
) as response:
|
|
||||||
if response.status == 200:
|
|
||||||
data = await response.json()
|
|
||||||
|
|
||||||
if data and 'vat' in data:
|
|
||||||
logger.info(f"✅ Validated CVR {cvr_clean}")
|
|
||||||
return {
|
|
||||||
'cvr': data.get('vat'),
|
|
||||||
'name': data.get('name'),
|
|
||||||
'address': data.get('address'),
|
|
||||||
'city': data.get('city'),
|
|
||||||
'zipcode': data.get('zipcode'),
|
|
||||||
'postal_code': data.get('zipcode'), # Alias for consistency
|
|
||||||
'country': data.get('country'),
|
|
||||||
'phone': data.get('phone'),
|
|
||||||
'email': data.get('email'),
|
|
||||||
'vat': data.get('vat'),
|
|
||||||
'status': data.get('status')
|
|
||||||
}
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ CVR validation error for {cvr_number}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_cvr_service() -> CVRService:
|
|
||||||
"""Get CVR service instance"""
|
|
||||||
return CVRService()
|
|
||||||
@ -1,656 +0,0 @@
|
|||||||
"""
|
|
||||||
e-conomic Integration Service
|
|
||||||
Send invoices and supplier invoices (kassekladde) to e-conomic accounting system
|
|
||||||
|
|
||||||
🚨 SAFETY MODES:
|
|
||||||
- ECONOMIC_READ_ONLY: Blocks ALL write operations when True
|
|
||||||
- ECONOMIC_DRY_RUN: Logs operations but doesn't send to e-conomic when True
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import aiohttp
|
|
||||||
import json
|
|
||||||
from typing import Dict, Optional, List
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class EconomicService:
|
|
||||||
"""Service for integrating with e-conomic REST API"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.api_url = getattr(settings, 'ECONOMIC_API_URL', 'https://restapi.e-conomic.com')
|
|
||||||
self.app_secret_token = getattr(settings, 'ECONOMIC_APP_SECRET_TOKEN', None)
|
|
||||||
self.agreement_grant_token = getattr(settings, 'ECONOMIC_AGREEMENT_GRANT_TOKEN', None)
|
|
||||||
self.read_only = getattr(settings, 'ECONOMIC_READ_ONLY', True)
|
|
||||||
self.dry_run = getattr(settings, 'ECONOMIC_DRY_RUN', True)
|
|
||||||
|
|
||||||
if not self.app_secret_token or not self.agreement_grant_token:
|
|
||||||
logger.warning("⚠️ e-conomic credentials not configured")
|
|
||||||
|
|
||||||
# Log safety status at initialization
|
|
||||||
if self.read_only:
|
|
||||||
logger.warning("🔒 e-conomic READ-ONLY MODE ENABLED - All write operations will be blocked")
|
|
||||||
elif self.dry_run:
|
|
||||||
logger.warning("🏃 e-conomic DRY-RUN MODE ENABLED - Operations will be logged but not executed")
|
|
||||||
else:
|
|
||||||
logger.warning("⚠️ e-conomic WRITE MODE ACTIVE - Changes will be sent to production!")
|
|
||||||
|
|
||||||
def _check_write_permission(self, operation: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if write operations are allowed
|
|
||||||
|
|
||||||
Args:
|
|
||||||
operation: Name of the operation being attempted
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if operation should proceed, False if blocked
|
|
||||||
"""
|
|
||||||
if self.read_only:
|
|
||||||
logger.error(f"🚫 BLOCKED: {operation} - READ_ONLY mode is enabled")
|
|
||||||
logger.error("To enable writes, set ECONOMIC_READ_ONLY=false in .env")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.dry_run:
|
|
||||||
logger.warning(f"🏃 DRY-RUN: {operation} - Would execute but DRY_RUN mode is enabled")
|
|
||||||
logger.warning("To actually send to e-conomic, set ECONOMIC_DRY_RUN=false in .env")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Triple-check for production writes
|
|
||||||
logger.warning(f"⚠️ EXECUTING WRITE OPERATION: {operation}")
|
|
||||||
logger.warning(f"⚠️ This will modify production e-conomic at {self.api_url}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _log_api_call(self, method: str, endpoint: str, payload: Optional[Dict] = None,
|
|
||||||
response_data: Optional[Dict] = None, status_code: Optional[int] = None):
|
|
||||||
"""
|
|
||||||
Comprehensive logging of all API calls
|
|
||||||
|
|
||||||
Args:
|
|
||||||
method: HTTP method (GET, POST, etc.)
|
|
||||||
endpoint: API endpoint
|
|
||||||
payload: Request payload
|
|
||||||
response_data: Response data
|
|
||||||
status_code: HTTP status code
|
|
||||||
"""
|
|
||||||
log_entry = {
|
|
||||||
"method": method,
|
|
||||||
"endpoint": endpoint,
|
|
||||||
"api_url": self.api_url,
|
|
||||||
"read_only": self.read_only,
|
|
||||||
"dry_run": self.dry_run
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload:
|
|
||||||
log_entry["request_payload"] = payload
|
|
||||||
if response_data:
|
|
||||||
log_entry["response_data"] = response_data
|
|
||||||
if status_code:
|
|
||||||
log_entry["status_code"] = status_code
|
|
||||||
|
|
||||||
logger.info(f"📊 e-conomic API Call: {json.dumps(log_entry, indent=2, default=str)}")
|
|
||||||
|
|
||||||
def _get_headers(self) -> Dict[str, str]:
|
|
||||||
"""Get HTTP headers for e-conomic API"""
|
|
||||||
if not self.app_secret_token or not self.agreement_grant_token:
|
|
||||||
raise ValueError("e-conomic credentials not configured")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'X-AppSecretToken': self.app_secret_token,
|
|
||||||
'X-AgreementGrantToken': self.agreement_grant_token,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
|
|
||||||
async def test_connection(self) -> bool:
|
|
||||||
"""
|
|
||||||
Test e-conomic API connection
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if connection successful
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(
|
|
||||||
f"{self.api_url}/self",
|
|
||||||
headers=self._get_headers()
|
|
||||||
) as response:
|
|
||||||
if response.status == 200:
|
|
||||||
data = await response.json()
|
|
||||||
logger.info(f"✅ Connected to e-conomic: {data.get('agreementNumber')}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
error = await response.text()
|
|
||||||
logger.error(f"❌ e-conomic connection failed: {response.status} - {error}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ e-conomic connection error: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ========== SUPPLIER/VENDOR MANAGEMENT ==========
|
|
||||||
|
|
||||||
async def search_supplier_by_name(self, supplier_name: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Search for supplier in e-conomic based on name
|
|
||||||
|
|
||||||
Args:
|
|
||||||
supplier_name: Name of supplier to search for
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Supplier data if found, None otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
url = f"{self.api_url}/suppliers"
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(url, headers=self._get_headers()) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
logger.error(f"❌ Failed to fetch suppliers: {response.status}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = await response.json()
|
|
||||||
suppliers = data.get('collection', [])
|
|
||||||
|
|
||||||
# Search for supplier by name (case-insensitive)
|
|
||||||
search_name = supplier_name.lower().strip()
|
|
||||||
|
|
||||||
for supplier in suppliers:
|
|
||||||
supplier_display_name = supplier.get('name', '').lower().strip()
|
|
||||||
|
|
||||||
# Exact match or contains
|
|
||||||
if search_name in supplier_display_name or supplier_display_name in search_name:
|
|
||||||
logger.info(f"✅ Found supplier match: {supplier.get('name')} (ID: {supplier.get('supplierNumber')})")
|
|
||||||
return {
|
|
||||||
'supplierNumber': supplier.get('supplierNumber'),
|
|
||||||
'name': supplier.get('name'),
|
|
||||||
'currency': supplier.get('currency'),
|
|
||||||
'vatZone': supplier.get('vatZone')
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warning(f"⚠️ No supplier found matching '{supplier_name}'")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error searching supplier: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def create_supplier(self, supplier_data: Dict) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Create new supplier in e-conomic
|
|
||||||
|
|
||||||
🚨 WRITE OPERATION - Respects READ_ONLY and DRY_RUN modes
|
|
||||||
|
|
||||||
Args:
|
|
||||||
supplier_data: {
|
|
||||||
'name': str,
|
|
||||||
'address': str (optional),
|
|
||||||
'city': str (optional),
|
|
||||||
'zip': str (optional),
|
|
||||||
'country': str (optional),
|
|
||||||
'corporate_identification_number': str (optional - CVR),
|
|
||||||
'currency': str (default 'DKK'),
|
|
||||||
'payment_terms_number': int (default 1),
|
|
||||||
'vat_zone_number': int (default 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Created supplier data with supplierNumber or None if failed
|
|
||||||
"""
|
|
||||||
if not self._check_write_permission("create_supplier"):
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Build supplier payload
|
|
||||||
payload = {
|
|
||||||
"name": supplier_data['name'],
|
|
||||||
"currency": supplier_data.get('currency', 'DKK'),
|
|
||||||
"supplierGroup": {
|
|
||||||
"supplierGroupNumber": supplier_data.get('supplier_group_number', 1)
|
|
||||||
},
|
|
||||||
"paymentTerms": {
|
|
||||||
"paymentTermsNumber": supplier_data.get('payment_terms_number', 4) # Netto 14 dage
|
|
||||||
},
|
|
||||||
"vatZone": {
|
|
||||||
"vatZoneNumber": supplier_data.get('vat_zone_number', 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Optional fields
|
|
||||||
if supplier_data.get('address'):
|
|
||||||
payload['address'] = supplier_data['address']
|
|
||||||
if supplier_data.get('city'):
|
|
||||||
payload['city'] = supplier_data['city']
|
|
||||||
if supplier_data.get('zip'):
|
|
||||||
payload['zip'] = supplier_data['zip']
|
|
||||||
if supplier_data.get('country'):
|
|
||||||
payload['country'] = supplier_data['country']
|
|
||||||
if supplier_data.get('corporate_identification_number'):
|
|
||||||
payload['corporateIdentificationNumber'] = supplier_data['corporate_identification_number']
|
|
||||||
|
|
||||||
url = f"{self.api_url}/suppliers"
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.post(url, headers=self._get_headers(), json=payload) as response:
|
|
||||||
if response.status in [200, 201]:
|
|
||||||
result = await response.json()
|
|
||||||
logger.info(f"✅ Created supplier: {result.get('name')} (ID: {result.get('supplierNumber')})")
|
|
||||||
|
|
||||||
# Save to local vendors table
|
|
||||||
try:
|
|
||||||
from app.core.database import execute_insert
|
|
||||||
|
|
||||||
vendor_id = execute_insert("""
|
|
||||||
INSERT INTO vendors (
|
|
||||||
name,
|
|
||||||
cvr,
|
|
||||||
economic_supplier_number,
|
|
||||||
created_at
|
|
||||||
) VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT (economic_supplier_number)
|
|
||||||
DO UPDATE SET
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
cvr = EXCLUDED.cvr
|
|
||||||
""", (
|
|
||||||
result.get('name'),
|
|
||||||
supplier_data.get('corporate_identification_number'),
|
|
||||||
result.get('supplierNumber')
|
|
||||||
))
|
|
||||||
|
|
||||||
logger.info(f"✅ Saved supplier to local database (vendor_id: {vendor_id})")
|
|
||||||
except Exception as db_error:
|
|
||||||
logger.warning(f"⚠️ Could not save to local database: {db_error}")
|
|
||||||
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
error_text = await response.text()
|
|
||||||
logger.error(f"❌ Failed to create supplier: {response.status} - {error_text}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error creating supplier: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ========== KASSEKLADDE (JOURNALS/VOUCHERS) ==========
|
|
||||||
|
|
||||||
async def check_invoice_number_exists(self, invoice_number: str, journal_number: Optional[int] = None) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Check if an invoice number already exists in e-conomic journals
|
|
||||||
|
|
||||||
Args:
|
|
||||||
invoice_number: Invoice number to check
|
|
||||||
journal_number: Optional specific journal to search (if None, searches all)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with voucher info if found, None otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Search in vouchers (posted journal entries)
|
|
||||||
url = f"{self.api_url}/vouchers"
|
|
||||||
params = {
|
|
||||||
'filter': f'voucherNumber${invoice_number}', # e-conomic filter syntax
|
|
||||||
'pagesize': 100
|
|
||||||
}
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(url, headers=self._get_headers(), params=params) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
logger.warning(f"⚠️ Failed to search vouchers: {response.status}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = await response.json()
|
|
||||||
vouchers = data.get('collection', [])
|
|
||||||
|
|
||||||
# Check if any voucher matches the invoice number
|
|
||||||
for voucher in vouchers:
|
|
||||||
# Check if invoice number appears in voucher text or entries
|
|
||||||
if invoice_number in str(voucher):
|
|
||||||
logger.warning(f"⚠️ Invoice number {invoice_number} found in e-conomic voucher #{voucher.get('voucherNumber')}")
|
|
||||||
return {
|
|
||||||
'found_in': 'e-conomic',
|
|
||||||
'voucher_number': voucher.get('voucherNumber'),
|
|
||||||
'date': voucher.get('date'),
|
|
||||||
'journal': voucher.get('journal', {}).get('journalNumber')
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"✅ Invoice number {invoice_number} not found in e-conomic")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error checking invoice number in e-conomic: {e}")
|
|
||||||
# Don't block on e-conomic errors - assume not found
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_supplier_invoice_journals(self) -> list:
|
|
||||||
"""
|
|
||||||
Get all available journals for supplier invoices (kassekladde)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of journal dictionaries with journalNumber, name, and journalType
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
url = f"{self.api_url}/journals"
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(url, headers=self._get_headers()) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
error_text = await response.text()
|
|
||||||
raise Exception(f"e-conomic API error: {response.status} - {error_text}")
|
|
||||||
|
|
||||||
data = await response.json()
|
|
||||||
|
|
||||||
# Filter for supplier invoice journals
|
|
||||||
journals = []
|
|
||||||
for journal in data.get('collection', []):
|
|
||||||
journals.append({
|
|
||||||
'journalNumber': journal.get('journalNumber'),
|
|
||||||
'name': journal.get('name'),
|
|
||||||
'journalType': journal.get('journalType')
|
|
||||||
})
|
|
||||||
|
|
||||||
return journals
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error fetching journals: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def create_journal_supplier_invoice(self,
|
|
||||||
journal_number: int,
|
|
||||||
supplier_number: int,
|
|
||||||
invoice_number: str,
|
|
||||||
invoice_date: str,
|
|
||||||
total_amount: float,
|
|
||||||
vat_breakdown: Dict[str, float],
|
|
||||||
line_items: List[Dict] = None,
|
|
||||||
due_date: Optional[str] = None,
|
|
||||||
text: Optional[str] = None) -> Dict:
|
|
||||||
"""
|
|
||||||
Post supplier invoice to e-conomic kassekladde (journals API)
|
|
||||||
|
|
||||||
🚨 WRITE OPERATION - Respects READ_ONLY and DRY_RUN modes
|
|
||||||
|
|
||||||
Args:
|
|
||||||
journal_number: Journal/kassekladde number (from system_settings)
|
|
||||||
supplier_number: e-conomic supplier number
|
|
||||||
invoice_number: Supplier's invoice number
|
|
||||||
invoice_date: Invoice date (YYYY-MM-DD)
|
|
||||||
total_amount: Total invoice amount including VAT
|
|
||||||
vat_breakdown: Dict of {vat_code: {"net": X, "vat": Y, "gross": Z}} for each VAT group
|
|
||||||
line_items: List of line items with contra_account and vat_code
|
|
||||||
due_date: Payment due date (YYYY-MM-DD)
|
|
||||||
text: Invoice description
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with voucher details or error info
|
|
||||||
"""
|
|
||||||
# 🚨 SAFETY CHECK
|
|
||||||
if not self._check_write_permission("create_journal_supplier_invoice"):
|
|
||||||
return {"error": True, "message": "Write operations blocked by READ_ONLY or DRY_RUN mode"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Extract year from invoice date for accounting year
|
|
||||||
accounting_year = invoice_date[:4]
|
|
||||||
|
|
||||||
# Build supplier invoice entries - one per line item or per VAT group
|
|
||||||
supplier_invoices = []
|
|
||||||
|
|
||||||
# If we have line items with contra accounts, use those
|
|
||||||
if line_items and isinstance(line_items, list):
|
|
||||||
# Group lines by VAT code and contra account combination
|
|
||||||
line_groups = {}
|
|
||||||
for line in line_items:
|
|
||||||
vat_code = line.get('vat_code', 'I25')
|
|
||||||
contra_account = line.get('contra_account', '5810')
|
|
||||||
key = f"{vat_code}_{contra_account}"
|
|
||||||
|
|
||||||
if key not in line_groups:
|
|
||||||
line_groups[key] = {
|
|
||||||
'vat_code': vat_code,
|
|
||||||
'contra_account': contra_account,
|
|
||||||
'gross': 0,
|
|
||||||
'vat': 0,
|
|
||||||
'items': []
|
|
||||||
}
|
|
||||||
|
|
||||||
line_total = line.get('line_total', 0)
|
|
||||||
vat_amount = line.get('vat_amount', 0)
|
|
||||||
|
|
||||||
line_groups[key]['gross'] += line_total
|
|
||||||
line_groups[key]['vat'] += vat_amount
|
|
||||||
line_groups[key]['items'].append(line)
|
|
||||||
|
|
||||||
# Create entry for each group
|
|
||||||
for key, group in line_groups.items():
|
|
||||||
entry = {
|
|
||||||
"supplier": {
|
|
||||||
"supplierNumber": supplier_number
|
|
||||||
},
|
|
||||||
"amount": round(group['gross'], 2),
|
|
||||||
"contraAccount": {
|
|
||||||
"accountNumber": int(group['contra_account'])
|
|
||||||
},
|
|
||||||
"currency": {
|
|
||||||
"code": "DKK"
|
|
||||||
},
|
|
||||||
"date": invoice_date,
|
|
||||||
"supplierInvoiceNumber": invoice_number[:30] if invoice_number else ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add text with product descriptions
|
|
||||||
descriptions = [item.get('description', '') for item in group['items'][:2]]
|
|
||||||
entry_text = text if text else f"Faktura {invoice_number}"
|
|
||||||
if descriptions:
|
|
||||||
entry_text = f"{entry_text} - {', '.join(filter(None, descriptions))}"
|
|
||||||
entry["text"] = entry_text[:250]
|
|
||||||
|
|
||||||
if due_date:
|
|
||||||
entry["dueDate"] = due_date
|
|
||||||
|
|
||||||
# Add VAT details
|
|
||||||
if group['vat'] > 0:
|
|
||||||
entry["contraVatAccount"] = {
|
|
||||||
"vatCode": group['vat_code']
|
|
||||||
}
|
|
||||||
entry["contraVatAmount"] = round(group['vat'], 2)
|
|
||||||
|
|
||||||
supplier_invoices.append(entry)
|
|
||||||
|
|
||||||
elif vat_breakdown and isinstance(vat_breakdown, dict):
|
|
||||||
# Fallback: vat_breakdown format: {"I25": {"net": 1110.672, "vat": 277.668, "rate": 25, "gross": 1388.34}, ...}
|
|
||||||
for vat_code, vat_data in vat_breakdown.items():
|
|
||||||
if not isinstance(vat_data, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
net_amount = vat_data.get('net', 0)
|
|
||||||
vat_amount = vat_data.get('vat', 0)
|
|
||||||
gross_amount = vat_data.get('gross', net_amount + vat_amount)
|
|
||||||
|
|
||||||
if gross_amount <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
entry = {
|
|
||||||
"supplier": {
|
|
||||||
"supplierNumber": supplier_number
|
|
||||||
},
|
|
||||||
"amount": round(gross_amount, 2),
|
|
||||||
"contraAccount": {
|
|
||||||
"accountNumber": 5810 # Default fallback account
|
|
||||||
},
|
|
||||||
"currency": {
|
|
||||||
"code": "DKK"
|
|
||||||
},
|
|
||||||
"date": invoice_date,
|
|
||||||
"supplierInvoiceNumber": invoice_number[:30] if invoice_number else ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add text with VAT code for clarity
|
|
||||||
entry_text = text if text else f"Faktura {invoice_number}"
|
|
||||||
if len(vat_breakdown) > 1:
|
|
||||||
entry_text = f"{entry_text} ({vat_code})"
|
|
||||||
entry["text"] = entry_text[:250]
|
|
||||||
|
|
||||||
if due_date:
|
|
||||||
entry["dueDate"] = due_date
|
|
||||||
|
|
||||||
# Add VAT details
|
|
||||||
if vat_amount > 0:
|
|
||||||
entry["contraVatAccount"] = {
|
|
||||||
"vatCode": vat_code
|
|
||||||
}
|
|
||||||
entry["contraVatAmount"] = round(vat_amount, 2)
|
|
||||||
|
|
||||||
supplier_invoices.append(entry)
|
|
||||||
else:
|
|
||||||
# No VAT breakdown - create single entry
|
|
||||||
supplier_invoice = {
|
|
||||||
"supplier": {
|
|
||||||
"supplierNumber": supplier_number
|
|
||||||
},
|
|
||||||
"amount": total_amount,
|
|
||||||
"contraAccount": {
|
|
||||||
"accountNumber": 5810 # Default fallback account
|
|
||||||
},
|
|
||||||
"currency": {
|
|
||||||
"code": "DKK"
|
|
||||||
},
|
|
||||||
"date": invoice_date,
|
|
||||||
"supplierInvoiceNumber": invoice_number[:30] if invoice_number else ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if text:
|
|
||||||
supplier_invoice["text"] = text[:250]
|
|
||||||
if due_date:
|
|
||||||
supplier_invoice["dueDate"] = due_date
|
|
||||||
|
|
||||||
supplier_invoices.append(supplier_invoice)
|
|
||||||
|
|
||||||
# Build voucher payload
|
|
||||||
payload = {
|
|
||||||
"accountingYear": {
|
|
||||||
"year": accounting_year
|
|
||||||
},
|
|
||||||
"journal": {
|
|
||||||
"journalNumber": journal_number
|
|
||||||
},
|
|
||||||
"entries": {
|
|
||||||
"supplierInvoices": supplier_invoices
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"📤 Posting supplier invoice to journal {journal_number}")
|
|
||||||
logger.debug(f"Payload: {json.dumps(payload, indent=2)}")
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.post(
|
|
||||||
f"{self.api_url}/journals/{journal_number}/vouchers",
|
|
||||||
headers=self._get_headers(),
|
|
||||||
json=payload
|
|
||||||
) as response:
|
|
||||||
response_text = await response.text()
|
|
||||||
|
|
||||||
self._log_api_call(
|
|
||||||
"POST",
|
|
||||||
f"/journals/{journal_number}/vouchers",
|
|
||||||
payload,
|
|
||||||
await response.json() if response.status in [200, 201] and response_text else None,
|
|
||||||
response.status
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status in [200, 201]:
|
|
||||||
data = await response.json() if response_text else {}
|
|
||||||
|
|
||||||
# e-conomic returns array of created vouchers
|
|
||||||
if isinstance(data, list) and len(data) > 0:
|
|
||||||
voucher_data = data[0]
|
|
||||||
else:
|
|
||||||
voucher_data = data
|
|
||||||
|
|
||||||
voucher_number = voucher_data.get('voucherNumber')
|
|
||||||
logger.info(f"✅ Supplier invoice posted to kassekladde: voucher #{voucher_number}")
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"voucher_number": voucher_number,
|
|
||||||
"journal_number": journal_number,
|
|
||||||
"accounting_year": accounting_year,
|
|
||||||
"data": voucher_data
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
logger.error(f"❌ Post to kassekladde failed: {response.status}")
|
|
||||||
logger.error(f"Response: {response_text}")
|
|
||||||
return {
|
|
||||||
"error": True,
|
|
||||||
"status": response.status,
|
|
||||||
"message": response_text
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ create_journal_supplier_invoice error: {e}")
|
|
||||||
logger.exception("Full traceback:")
|
|
||||||
return {"error": True, "status": 500, "message": str(e)}
|
|
||||||
|
|
||||||
async def upload_voucher_attachment(self,
|
|
||||||
journal_number: int,
|
|
||||||
accounting_year: str,
|
|
||||||
voucher_number: int,
|
|
||||||
pdf_path: str,
|
|
||||||
filename: str) -> Dict:
|
|
||||||
"""
|
|
||||||
Upload PDF attachment to e-conomic voucher
|
|
||||||
|
|
||||||
🚨 WRITE OPERATION - Respects READ_ONLY and DRY_RUN modes
|
|
||||||
|
|
||||||
Args:
|
|
||||||
journal_number: Journal number
|
|
||||||
accounting_year: Accounting year (e.g., "2025")
|
|
||||||
voucher_number: Voucher number
|
|
||||||
pdf_path: Local path to PDF file
|
|
||||||
filename: Filename for attachment
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with success status
|
|
||||||
"""
|
|
||||||
# 🚨 SAFETY CHECK
|
|
||||||
if not self._check_write_permission("upload_voucher_attachment"):
|
|
||||||
return {"error": True, "message": "Write operations blocked by READ_ONLY or DRY_RUN mode"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Read PDF file
|
|
||||||
with open(pdf_path, 'rb') as f:
|
|
||||||
pdf_data = f.read()
|
|
||||||
|
|
||||||
# e-conomic attachment/file endpoint (POST is allowed here, not on /attachment)
|
|
||||||
url = f"{self.api_url}/journals/{journal_number}/vouchers/{accounting_year}-{voucher_number}/attachment/file"
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'X-AppSecretToken': self.app_secret_token,
|
|
||||||
'X-AgreementGrantToken': self.agreement_grant_token
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use multipart/form-data as required by e-conomic API
|
|
||||||
form_data = aiohttp.FormData()
|
|
||||||
form_data.add_field('file',
|
|
||||||
pdf_data,
|
|
||||||
filename=filename,
|
|
||||||
content_type='application/pdf')
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.post(url, headers=headers, data=form_data) as response:
|
|
||||||
if response.status in [200, 201, 204]:
|
|
||||||
logger.info(f"📎 PDF attachment uploaded to voucher {accounting_year}-{voucher_number}")
|
|
||||||
return {"success": True}
|
|
||||||
else:
|
|
||||||
error_text = await response.text()
|
|
||||||
logger.error(f"❌ Failed to upload attachment: {response.status} - {error_text}")
|
|
||||||
return {"error": True, "status": response.status, "message": error_text}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ upload_voucher_attachment error: {e}")
|
|
||||||
return {"error": True, "message": str(e)}
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
|
||||||
_economic_service_instance = None
|
|
||||||
|
|
||||||
def get_economic_service() -> EconomicService:
|
|
||||||
"""Get singleton instance of EconomicService"""
|
|
||||||
global _economic_service_instance
|
|
||||||
if _economic_service_instance is None:
|
|
||||||
_economic_service_instance = EconomicService()
|
|
||||||
return _economic_service_instance
|
|
||||||
@ -1,337 +0,0 @@
|
|||||||
"""
|
|
||||||
Invoice2Data Service
|
|
||||||
Wrapper around invoice2data library for template-based invoice extraction
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional, Any
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class Invoice2DataService:
|
|
||||||
"""Service for extracting invoice data using invoice2data templates"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.template_dir = Path(__file__).parent.parent.parent / "data" / "invoice_templates"
|
|
||||||
self.templates = self._load_templates()
|
|
||||||
logger.info(f"📋 Loaded {len(self.templates)} invoice2data templates")
|
|
||||||
|
|
||||||
def _load_templates(self) -> Dict[str, Dict]:
|
|
||||||
"""Load all YAML templates from template directory"""
|
|
||||||
templates = {}
|
|
||||||
|
|
||||||
if not self.template_dir.exists():
|
|
||||||
logger.warning(f"Template directory not found: {self.template_dir}")
|
|
||||||
return templates
|
|
||||||
|
|
||||||
for template_file in self.template_dir.glob("*.yml"):
|
|
||||||
try:
|
|
||||||
with open(template_file, 'r', encoding='utf-8') as f:
|
|
||||||
template_data = yaml.safe_load(f)
|
|
||||||
template_name = template_file.stem
|
|
||||||
templates[template_name] = template_data
|
|
||||||
logger.debug(f" ✓ Loaded template: {template_name}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f" ✗ Failed to load template {template_file}: {e}")
|
|
||||||
|
|
||||||
return templates
|
|
||||||
|
|
||||||
def match_template(self, text: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Find matching template based on keywords
|
|
||||||
Returns template name or None
|
|
||||||
"""
|
|
||||||
text_lower = text.lower()
|
|
||||||
|
|
||||||
for template_name, template_data in self.templates.items():
|
|
||||||
keywords = template_data.get('keywords', [])
|
|
||||||
|
|
||||||
# Check if all keywords are present
|
|
||||||
matches = sum(1 for keyword in keywords if str(keyword).lower() in text_lower)
|
|
||||||
|
|
||||||
if matches >= len(keywords) * 0.7: # 70% of keywords must match
|
|
||||||
logger.info(f"✅ Matched template: {template_name} ({matches}/{len(keywords)} keywords)")
|
|
||||||
return template_name
|
|
||||||
|
|
||||||
logger.warning("⚠️ No template matched")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def extract_with_template(self, text: str, template_name: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Extract invoice data using specific template
|
|
||||||
"""
|
|
||||||
if template_name not in self.templates:
|
|
||||||
raise ValueError(f"Template not found: {template_name}")
|
|
||||||
|
|
||||||
template = self.templates[template_name]
|
|
||||||
fields = template.get('fields', {})
|
|
||||||
options = template.get('options', {})
|
|
||||||
|
|
||||||
extracted = {
|
|
||||||
'template': template_name,
|
|
||||||
'issuer': template.get('issuer'),
|
|
||||||
'country': template.get('country'),
|
|
||||||
'currency': options.get('currency', 'DKK')
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract each field using its regex
|
|
||||||
for field_name, field_config in fields.items():
|
|
||||||
if field_config.get('parser') != 'regex':
|
|
||||||
continue
|
|
||||||
|
|
||||||
pattern = field_config.get('regex')
|
|
||||||
field_type = field_config.get('type', 'string')
|
|
||||||
group = field_config.get('group', 1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE)
|
|
||||||
|
|
||||||
if match:
|
|
||||||
value = match.group(group).strip()
|
|
||||||
|
|
||||||
logger.debug(f" 🔍 Extracted raw value for {field_name}: '{value}' (type: {field_type})")
|
|
||||||
|
|
||||||
# Handle CVR filtering (avoid customer CVR)
|
|
||||||
if field_name == 'vendor_vat':
|
|
||||||
# Find ALL CVR numbers
|
|
||||||
all_cvr_matches = re.finditer(r'SE/CVR-nr\.\s+(\d{8})', text, re.IGNORECASE)
|
|
||||||
cvr_numbers = [m.group(1) for m in all_cvr_matches]
|
|
||||||
|
|
||||||
# Filter out BMC's CVR (29522790)
|
|
||||||
vendor_cvrs = [cvr for cvr in cvr_numbers if cvr != '29522790']
|
|
||||||
|
|
||||||
if vendor_cvrs:
|
|
||||||
value = vendor_cvrs[0]
|
|
||||||
logger.debug(f" ✓ {field_name}: {value} (filtered from {cvr_numbers})")
|
|
||||||
else:
|
|
||||||
logger.warning(f" ⚠️ Only customer CVR found, no vendor CVR")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Convert type
|
|
||||||
if field_type == 'float':
|
|
||||||
# Handle Danish number format (1.234,56 → 1234.56)
|
|
||||||
# OR (148,587.98 → 148587.98) - handle both formats
|
|
||||||
decimal_sep = options.get('decimal_separator', ',')
|
|
||||||
thousands_sep = options.get('thousands_separator', '.')
|
|
||||||
|
|
||||||
# Remove all spaces first
|
|
||||||
value = value.replace(' ', '')
|
|
||||||
|
|
||||||
# If both separators are present, we can determine the format
|
|
||||||
# Danish: 148.587,98 (thousands=., decimal=,)
|
|
||||||
# English: 148,587.98 (thousands=, decimal=.)
|
|
||||||
if thousands_sep in value and decimal_sep in value:
|
|
||||||
# Remove thousands separator, then convert decimal separator to .
|
|
||||||
value = value.replace(thousands_sep, '').replace(decimal_sep, '.')
|
|
||||||
elif thousands_sep in value:
|
|
||||||
# Only thousands separator present - just remove it
|
|
||||||
value = value.replace(thousands_sep, '')
|
|
||||||
elif decimal_sep in value and decimal_sep == ',':
|
|
||||||
# Only decimal separator and it's Danish comma - convert to .
|
|
||||||
value = value.replace(',', '.')
|
|
||||||
|
|
||||||
value = float(value)
|
|
||||||
elif field_type == 'int':
|
|
||||||
value = int(value)
|
|
||||||
elif field_type == 'date':
|
|
||||||
# Try to parse Danish dates
|
|
||||||
date_formats = options.get('date_formats', ['%B %d, %Y', '%d-%m-%Y'])
|
|
||||||
|
|
||||||
# Danish month names
|
|
||||||
value = value.replace('januar', 'January').replace('februar', 'February')
|
|
||||||
value = value.replace('marts', 'March').replace('april', 'April')
|
|
||||||
value = value.replace('maj', 'May').replace('juni', 'June')
|
|
||||||
value = value.replace('juli', 'July').replace('august', 'August')
|
|
||||||
value = value.replace('september', 'September').replace('oktober', 'October')
|
|
||||||
value = value.replace('november', 'November').replace('december', 'December')
|
|
||||||
|
|
||||||
for date_format in date_formats:
|
|
||||||
try:
|
|
||||||
parsed_date = datetime.strptime(value, date_format)
|
|
||||||
value = parsed_date.strftime('%Y-%m-%d')
|
|
||||||
break
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
extracted[field_name] = value
|
|
||||||
logger.debug(f" ✓ {field_name}: {value}")
|
|
||||||
else:
|
|
||||||
logger.debug(f" ✗ {field_name}: No match")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f" ✗ Failed to extract {field_name}: {e}")
|
|
||||||
|
|
||||||
# Extract line items if defined in template
|
|
||||||
lines_config = template.get('lines', [])
|
|
||||||
if lines_config:
|
|
||||||
extracted['lines'] = self._extract_lines(text, lines_config, options)
|
|
||||||
|
|
||||||
return extracted
|
|
||||||
|
|
||||||
def _extract_lines(self, text: str, lines_configs: List[Dict], options: Dict) -> List[Dict]:
|
|
||||||
"""Extract line items from invoice text"""
|
|
||||||
all_lines = []
|
|
||||||
|
|
||||||
logger.debug(f"🔍 Extracting lines with {len(lines_configs)} configurations")
|
|
||||||
|
|
||||||
for lines_config in lines_configs:
|
|
||||||
start_pattern = lines_config.get('start')
|
|
||||||
end_pattern = lines_config.get('end')
|
|
||||||
line_config = lines_config.get('line', {})
|
|
||||||
|
|
||||||
if not start_pattern or not line_config:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Find section between start and end patterns
|
|
||||||
if end_pattern:
|
|
||||||
section_pattern = f"{start_pattern}(.*?){end_pattern}"
|
|
||||||
section_match = re.search(section_pattern, text, re.DOTALL | re.IGNORECASE)
|
|
||||||
else:
|
|
||||||
section_pattern = f"{start_pattern}(.*?)$"
|
|
||||||
section_match = re.search(section_pattern, text, re.DOTALL | re.IGNORECASE)
|
|
||||||
|
|
||||||
if not section_match:
|
|
||||||
logger.debug(f" ✗ Line section not found (start: {start_pattern[:50]}, end: {end_pattern[:50] if end_pattern else 'None'})")
|
|
||||||
continue
|
|
||||||
|
|
||||||
section_text = section_match.group(1)
|
|
||||||
logger.debug(f" ✓ Found line section ({len(section_text)} chars)")
|
|
||||||
|
|
||||||
# Extract individual lines
|
|
||||||
line_pattern = line_config.get('regex')
|
|
||||||
field_names = line_config.get('fields', [])
|
|
||||||
field_types = line_config.get('types', {})
|
|
||||||
context_config = line_config.get('context_before', {})
|
|
||||||
|
|
||||||
if not line_pattern or not field_names:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Split section into lines for context processing
|
|
||||||
section_lines = section_text.split('\n')
|
|
||||||
line_matches = []
|
|
||||||
|
|
||||||
# Find all matching lines with their indices
|
|
||||||
for line_idx, line_text in enumerate(section_lines):
|
|
||||||
match = re.search(line_pattern, line_text, re.MULTILINE)
|
|
||||||
if match:
|
|
||||||
line_matches.append((line_idx, line_text, match))
|
|
||||||
|
|
||||||
logger.debug(f" ✓ Found {len(line_matches)} matching lines")
|
|
||||||
|
|
||||||
for line_idx, line_text, match in line_matches:
|
|
||||||
line_data = {}
|
|
||||||
|
|
||||||
# Extract main line fields
|
|
||||||
for idx, field_name in enumerate(field_names, start=1):
|
|
||||||
try:
|
|
||||||
value = match.group(idx).strip()
|
|
||||||
field_type = field_types.get(field_name, 'string')
|
|
||||||
|
|
||||||
# Convert type
|
|
||||||
if field_type == 'float':
|
|
||||||
thousands_sep = options.get('thousands_separator', ',')
|
|
||||||
decimal_sep = options.get('decimal_separator', '.')
|
|
||||||
value = value.replace(' ', '')
|
|
||||||
|
|
||||||
if thousands_sep in value and decimal_sep in value:
|
|
||||||
value = value.replace(thousands_sep, '').replace(decimal_sep, '.')
|
|
||||||
elif thousands_sep in value:
|
|
||||||
value = value.replace(thousands_sep, '')
|
|
||||||
elif decimal_sep in value and decimal_sep == ',':
|
|
||||||
value = value.replace(',', '.')
|
|
||||||
|
|
||||||
value = float(value)
|
|
||||||
elif field_type == 'int':
|
|
||||||
value = int(value)
|
|
||||||
|
|
||||||
line_data[field_name] = value
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f" ✗ Failed to extract line field {field_name}: {e}")
|
|
||||||
|
|
||||||
# Extract context_before if configured
|
|
||||||
if context_config and line_idx > 0:
|
|
||||||
max_lines = context_config.get('max_lines', 5)
|
|
||||||
patterns = context_config.get('patterns', [])
|
|
||||||
|
|
||||||
# Look at lines BEFORE this line
|
|
||||||
start_idx = max(0, line_idx - max_lines)
|
|
||||||
context_lines = section_lines[start_idx:line_idx]
|
|
||||||
|
|
||||||
for pattern_config in patterns:
|
|
||||||
pattern_regex = pattern_config.get('regex')
|
|
||||||
pattern_fields = pattern_config.get('fields', [])
|
|
||||||
|
|
||||||
if not pattern_regex or not pattern_fields:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Try to match against context lines (most recent first)
|
|
||||||
for ctx_line in reversed(context_lines):
|
|
||||||
ctx_match = re.search(pattern_regex, ctx_line)
|
|
||||||
if ctx_match:
|
|
||||||
# Extract fields from context
|
|
||||||
for ctx_idx, ctx_field_name in enumerate(pattern_fields, start=1):
|
|
||||||
try:
|
|
||||||
ctx_value = ctx_match.group(ctx_idx).strip()
|
|
||||||
line_data[ctx_field_name] = ctx_value
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f" ✗ Failed to extract context field {ctx_field_name}: {e}")
|
|
||||||
break # Stop after first match for this pattern
|
|
||||||
|
|
||||||
if line_data:
|
|
||||||
all_lines.append(line_data)
|
|
||||||
|
|
||||||
logger.info(f" ✓ Extracted {len(all_lines)} line items")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f" ✗ Failed to extract lines: {e}")
|
|
||||||
|
|
||||||
return all_lines
|
|
||||||
|
|
||||||
def extract(self, text: str, template_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Extract invoice data from text
|
|
||||||
If template_name is None, auto-detect template
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Auto-detect template if not specified
|
|
||||||
if template_name is None:
|
|
||||||
template_name = self.match_template(text)
|
|
||||||
if template_name is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Extract with template
|
|
||||||
result = self.extract_with_template(text, template_name)
|
|
||||||
|
|
||||||
logger.info(f"✅ Extracted {len(result)} fields using template: {template_name}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Extraction failed: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_template_list(self) -> List[Dict[str, str]]:
|
|
||||||
"""Get list of available templates"""
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
'name': name,
|
|
||||||
'issuer': template.get('issuer'),
|
|
||||||
'country': template.get('country')
|
|
||||||
}
|
|
||||||
for name, template in self.templates.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
|
||||||
_invoice2data_service = None
|
|
||||||
|
|
||||||
def get_invoice2data_service() -> Invoice2DataService:
|
|
||||||
"""Get singleton instance of Invoice2Data service"""
|
|
||||||
global _invoice2data_service
|
|
||||||
if _invoice2data_service is None:
|
|
||||||
_invoice2data_service = Invoice2DataService()
|
|
||||||
return _invoice2data_service
|
|
||||||
@ -1,600 +0,0 @@
|
|||||||
"""
|
|
||||||
Ollama Integration Service for BMC Hub
|
|
||||||
Handles supplier invoice extraction using Ollama LLM with CVR matching
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional, Dict, List, Tuple
|
|
||||||
from datetime import datetime
|
|
||||||
import re
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.database import execute_insert, execute_query, execute_update
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class OllamaService:
|
|
||||||
"""Service for extracting supplier invoice data using Ollama LLM"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.endpoint = settings.OLLAMA_ENDPOINT
|
|
||||||
self.model = settings.OLLAMA_MODEL
|
|
||||||
self.system_prompt = self._build_system_prompt()
|
|
||||||
logger.info(f"🤖 Initialized OllamaService: {self.endpoint}, model={self.model}")
|
|
||||||
|
|
||||||
def _build_system_prompt(self) -> str:
|
|
||||||
"""Build Danish system prompt for invoice extraction with CVR"""
|
|
||||||
return """Du er en ekspert i at læse og udtrække strukturerede data fra danske fakturaer, kreditnotaer og leverandørdokumenter.
|
|
||||||
|
|
||||||
VIGTIGE REGLER:
|
|
||||||
1. Returner KUN gyldig JSON - ingen forklaring eller ekstra tekst
|
|
||||||
2. Hvis et felt ikke findes, sæt det til null
|
|
||||||
3. Beregn confidence baseret på hvor sikker du er på hvert felt (0.0-1.0)
|
|
||||||
4. Datoer skal være i format YYYY-MM-DD
|
|
||||||
5. DANSKE PRISFORMATER:
|
|
||||||
- Tusind-separator kan være . (punkt) eller mellemrum: "5.965,18" eller "5 965,18"
|
|
||||||
- Decimal-separator er , (komma): "1.234,56 kr"
|
|
||||||
- I JSON output skal du bruge . (punkt) som decimal: 1234.56
|
|
||||||
- Eksempel: "5.965,18 kr" → 5965.18 i JSON
|
|
||||||
- Eksempel: "1.234,56 DKK" → 1234.56 i JSON
|
|
||||||
6. CVR-nummer skal være 8 cifre uden mellemrum
|
|
||||||
7. Moms/VAT skal udtrækkes fra hver linje hvis muligt
|
|
||||||
8. DOKUMENTTYPE DETEKTION:
|
|
||||||
- "invoice" = Almindelig faktura
|
|
||||||
- "credit_note" = Kreditnota (refusion, tilbagebetaling, korrektion)
|
|
||||||
- Kig efter ord som: "Kreditnota", "Credit Note", "Refusion", "Tilbagebetaling", "Godtgørelse"
|
|
||||||
9. BELØB OG FORTEGN (ABSOLUT KRITISK):
|
|
||||||
- **ALMINDELIGE FAKTURAER**: Alle beløb skal være POSITIVE tal (total_amount > 0, line_total > 0)
|
|
||||||
- **KREDITNOTAER**: Alle beløb skal være NEGATIVE tal (total_amount < 0, line_total < 0)
|
|
||||||
- Hvis dokumentet siger "Faktura" → document_type: "invoice" → POSITIVE beløb
|
|
||||||
- Hvis dokumentet siger "Kreditnota" → document_type: "credit_note" → NEGATIVE beløb
|
|
||||||
|
|
||||||
JSON format skal være:
|
|
||||||
{
|
|
||||||
"document_type": "invoice" eller "credit_note",
|
|
||||||
"invoice_number": "fakturanummer eller kreditnota nummer",
|
|
||||||
"vendor_name": "leverandør firmanavn",
|
|
||||||
"vendor_cvr": "12345678",
|
|
||||||
"invoice_date": "YYYY-MM-DD",
|
|
||||||
"due_date": "YYYY-MM-DD",
|
|
||||||
"currency": "DKK",
|
|
||||||
"total_amount": 1234.56 (NEGATIVT for kreditnotaer),
|
|
||||||
"vat_amount": 123.45 (NEGATIVT for kreditnotaer),
|
|
||||||
"original_invoice_reference": "reference til original faktura (kun for kreditnotaer)",
|
|
||||||
"lines": [
|
|
||||||
{
|
|
||||||
"line_number": 1,
|
|
||||||
"description": "beskrivelse af varen/ydelsen",
|
|
||||||
"quantity": antal_som_tal,
|
|
||||||
"unit_price": pris_per_stk (NEGATIVT for kreditnotaer),
|
|
||||||
"line_total": total_for_linjen (NEGATIVT for kreditnotaer),
|
|
||||||
"vat_rate": 25.00,
|
|
||||||
"vat_amount": moms_beløb (NEGATIVT for kreditnotaer),
|
|
||||||
"confidence": 0.0_til_1.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"confidence": gennemsnits_confidence,
|
|
||||||
"raw_text_snippet": "første 200 tegn fra dokumentet"
|
|
||||||
}
|
|
||||||
|
|
||||||
EKSEMPEL PÅ FAKTURA (POSITIVE BELØB):
|
|
||||||
Input: "FAKTURA 2025-001\\nGlobalConnect A/S\\nCVR: 12345678\\n1 stk iPhone 16 @ 5.965,18 DKK\\nMoms (25%): 1.491,30 DKK\\nTotal: 7.456,48 DKK"
|
|
||||||
|
|
||||||
Output: {
|
|
||||||
"document_type": "invoice",
|
|
||||||
"invoice_number": "2025-001",
|
|
||||||
"vendor_name": "GlobalConnect A/S",
|
|
||||||
"vendor_cvr": "12345678",
|
|
||||||
"total_amount": 7456.48,
|
|
||||||
"vat_amount": 1491.30,
|
|
||||||
"lines": [{
|
|
||||||
"line_number": 1,
|
|
||||||
"description": "iPhone 16",
|
|
||||||
"quantity": 1,
|
|
||||||
"unit_price": 5965.18,
|
|
||||||
"line_total": 5965.18,
|
|
||||||
"vat_rate": 25.00,
|
|
||||||
"vat_amount": 1491.30,
|
|
||||||
"confidence": 0.95
|
|
||||||
}],
|
|
||||||
"confidence": 0.95
|
|
||||||
}
|
|
||||||
|
|
||||||
EKSEMPEL PÅ KREDITNOTA (NEGATIVE BELØB):
|
|
||||||
Input: "KREDITNOTA CN-2025-042\\nGlobalConnect A/S\\nCVR: 12345678\\nReference: Faktura 2025-001\\nTilbagebetaling:\\n1 stk iPhone 16 returneret @ -5.965,18 DKK\\nMoms (25%): -1.491,30 DKK\\nTotal: -7.456,48 DKK"
|
|
||||||
|
|
||||||
Output: {
|
|
||||||
"document_type": "credit_note",
|
|
||||||
"invoice_number": "CN-2025-042",
|
|
||||||
"vendor_name": "GlobalConnect A/S",
|
|
||||||
"vendor_cvr": "12345678",
|
|
||||||
"original_invoice_reference": "2025-001",
|
|
||||||
"total_amount": -7456.48,
|
|
||||||
"vat_amount": -1491.30,
|
|
||||||
"lines": [{
|
|
||||||
"line_number": 1,
|
|
||||||
"description": "iPhone 16 returneret",
|
|
||||||
"quantity": 1,
|
|
||||||
"unit_price": -5965.18,
|
|
||||||
"line_total": -5965.18,
|
|
||||||
"vat_rate": 25.00,
|
|
||||||
"vat_amount": -1491.30,
|
|
||||||
"confidence": 0.95
|
|
||||||
}],
|
|
||||||
"confidence": 0.95
|
|
||||||
}"""
|
|
||||||
|
|
||||||
async def extract_from_text(self, text: str) -> Dict:
|
|
||||||
"""
|
|
||||||
Extract structured invoice data from text using Ollama
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Document text content
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Extracted data as dict with CVR, invoice number, amounts, etc.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# No truncation - send full text to AI
|
|
||||||
prompt = f"{self.system_prompt}\n\nNU SKAL DU UDTRÆKKE DATA FRA DENNE FAKTURA:\n{text}\n\nReturner kun gyldig JSON:"
|
|
||||||
|
|
||||||
logger.info(f"🤖 Extracting invoice data from text (length: {len(text)})")
|
|
||||||
|
|
||||||
try:
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
# Detect if using qwen3 model (requires Chat API)
|
|
||||||
use_chat_api = self.model.startswith('qwen3')
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=1000.0) as client:
|
|
||||||
if use_chat_api:
|
|
||||||
# qwen3 models use Chat API format
|
|
||||||
logger.info(f"🤖 Using Chat API for {self.model}")
|
|
||||||
response = await client.post(
|
|
||||||
f"{self.endpoint}/api/chat",
|
|
||||||
json={
|
|
||||||
"model": self.model,
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": self.system_prompt
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": f"NU SKAL DU UDTRÆKKE DATA FRA DENNE FAKTURA:\n{text}\n\nVIGTIGT: Dit svar skal STARTE med {{ og SLUTTE med }} - ingen forklaring før eller efter JSON!"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"stream": False,
|
|
||||||
"format": "json",
|
|
||||||
"options": {
|
|
||||||
"temperature": 0.1,
|
|
||||||
"top_p": 0.9,
|
|
||||||
"num_predict": 2000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# qwen2.5 and other models use Generate API format
|
|
||||||
logger.info(f"🤖 Using Generate API for {self.model}")
|
|
||||||
response = await client.post(
|
|
||||||
f"{self.endpoint}/api/generate",
|
|
||||||
json={
|
|
||||||
"model": self.model,
|
|
||||||
"prompt": prompt,
|
|
||||||
"stream": False,
|
|
||||||
"options": {
|
|
||||||
"temperature": 0.1,
|
|
||||||
"top_p": 0.9,
|
|
||||||
"num_predict": 2000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise Exception(f"Ollama returned status {response.status_code}: {response.text}")
|
|
||||||
|
|
||||||
result = response.json()
|
|
||||||
|
|
||||||
# Extract response based on API type
|
|
||||||
if use_chat_api:
|
|
||||||
# qwen3 models sometimes put the actual response in "thinking" field
|
|
||||||
raw_response = result.get("message", {}).get("content", "")
|
|
||||||
thinking = result.get("message", {}).get("thinking", "")
|
|
||||||
|
|
||||||
# If content is empty but thinking has data, try to extract JSON from thinking
|
|
||||||
if not raw_response and thinking:
|
|
||||||
logger.info(f"💭 Content empty, attempting to extract JSON from thinking field (length: {len(thinking)})")
|
|
||||||
# Try to find JSON block in thinking text
|
|
||||||
json_start = thinking.find('{')
|
|
||||||
json_end = thinking.rfind('}') + 1
|
|
||||||
if json_start >= 0 and json_end > json_start:
|
|
||||||
potential_json = thinking[json_start:json_end]
|
|
||||||
logger.info(f"📦 Found potential JSON in thinking field (length: {len(potential_json)})")
|
|
||||||
raw_response = potential_json
|
|
||||||
else:
|
|
||||||
logger.warning(f"⚠️ No JSON found in thinking field, using full thinking as fallback")
|
|
||||||
raw_response = thinking
|
|
||||||
elif thinking:
|
|
||||||
logger.info(f"💭 Model thinking (length: {len(thinking)})")
|
|
||||||
|
|
||||||
# DEBUG: Log full result structure
|
|
||||||
logger.info(f"📊 Chat API result keys: {list(result.keys())}")
|
|
||||||
logger.info(f"📊 Message keys: {list(result.get('message', {}).keys())}")
|
|
||||||
else:
|
|
||||||
raw_response = result.get("response", "")
|
|
||||||
|
|
||||||
logger.info(f"✅ Ollama extraction completed (response length: {len(raw_response)})")
|
|
||||||
|
|
||||||
# Parse JSON from response
|
|
||||||
extraction = self._parse_json_response(raw_response)
|
|
||||||
|
|
||||||
# CRITICAL: Fix amount signs based on document_type
|
|
||||||
# LLM sometimes returns negative amounts for invoices - fix this!
|
|
||||||
document_type = extraction.get('document_type', 'invoice')
|
|
||||||
|
|
||||||
if document_type == 'invoice':
|
|
||||||
# Normal invoices should have POSITIVE amounts
|
|
||||||
if extraction.get('total_amount') and extraction['total_amount'] < 0:
|
|
||||||
logger.warning(f"⚠️ Fixing negative total_amount for invoice: {extraction['total_amount']} → {abs(extraction['total_amount'])}")
|
|
||||||
extraction['total_amount'] = abs(extraction['total_amount'])
|
|
||||||
|
|
||||||
if extraction.get('vat_amount') and extraction['vat_amount'] < 0:
|
|
||||||
extraction['vat_amount'] = abs(extraction['vat_amount'])
|
|
||||||
|
|
||||||
# Fix line totals
|
|
||||||
if 'lines' in extraction:
|
|
||||||
for line in extraction['lines']:
|
|
||||||
if line.get('unit_price') and line['unit_price'] < 0:
|
|
||||||
line['unit_price'] = abs(line['unit_price'])
|
|
||||||
if line.get('line_total') and line['line_total'] < 0:
|
|
||||||
line['line_total'] = abs(line['line_total'])
|
|
||||||
if line.get('vat_amount') and line['vat_amount'] < 0:
|
|
||||||
line['vat_amount'] = abs(line['vat_amount'])
|
|
||||||
|
|
||||||
elif document_type == 'credit_note':
|
|
||||||
# Credit notes should have NEGATIVE amounts
|
|
||||||
if extraction.get('total_amount') and extraction['total_amount'] > 0:
|
|
||||||
logger.warning(f"⚠️ Fixing positive total_amount for credit_note: {extraction['total_amount']} → {-abs(extraction['total_amount'])}")
|
|
||||||
extraction['total_amount'] = -abs(extraction['total_amount'])
|
|
||||||
|
|
||||||
if extraction.get('vat_amount') and extraction['vat_amount'] > 0:
|
|
||||||
extraction['vat_amount'] = -abs(extraction['vat_amount'])
|
|
||||||
|
|
||||||
# Fix line totals
|
|
||||||
if 'lines' in extraction:
|
|
||||||
for line in extraction['lines']:
|
|
||||||
if line.get('unit_price') and line['unit_price'] > 0:
|
|
||||||
line['unit_price'] = -abs(line['unit_price'])
|
|
||||||
if line.get('line_total') and line['line_total'] > 0:
|
|
||||||
line['line_total'] = -abs(line['line_total'])
|
|
||||||
if line.get('vat_amount') and line['vat_amount'] > 0:
|
|
||||||
line['vat_amount'] = -abs(line['vat_amount'])
|
|
||||||
|
|
||||||
# Add raw response for debugging
|
|
||||||
extraction['_raw_llm_response'] = raw_response
|
|
||||||
|
|
||||||
return extraction
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Ollama extraction failed: {str(e)}"
|
|
||||||
logger.error(f"❌ {error_msg}")
|
|
||||||
|
|
||||||
error_str = str(e).lower()
|
|
||||||
if "timeout" in error_str:
|
|
||||||
return {
|
|
||||||
"error": f"Ollama timeout efter 1000 sekunder",
|
|
||||||
"confidence": 0.0
|
|
||||||
}
|
|
||||||
elif "connection" in error_str or "connect" in error_str:
|
|
||||||
return {
|
|
||||||
"error": f"Kan ikke forbinde til Ollama på {self.endpoint}",
|
|
||||||
"confidence": 0.0
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"error": error_msg,
|
|
||||||
"confidence": 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
def _parse_json_response(self, response: str) -> Dict:
|
|
||||||
"""Parse JSON from LLM response with improved error handling"""
|
|
||||||
try:
|
|
||||||
# Log preview of response for debugging
|
|
||||||
logger.info(f"🔍 Response preview (first 500 chars): {response[:500]}")
|
|
||||||
|
|
||||||
# Find JSON in response (between first { and last })
|
|
||||||
start = response.find('{')
|
|
||||||
end = response.rfind('}') + 1
|
|
||||||
|
|
||||||
if start >= 0 and end > start:
|
|
||||||
json_str = response[start:end]
|
|
||||||
logger.info(f"🔍 Extracted JSON string length: {len(json_str)}, starts at position {start}")
|
|
||||||
|
|
||||||
# Try to fix common JSON issues
|
|
||||||
# Remove trailing commas before } or ]
|
|
||||||
json_str = re.sub(r',(\s*[}\]])', r'\1', json_str)
|
|
||||||
# Fix single quotes to double quotes (but not in values)
|
|
||||||
# This is risky, so we only do it if initial parse fails
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(json_str)
|
|
||||||
return data
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Try to fix common issues
|
|
||||||
# Replace single quotes with double quotes (simple approach)
|
|
||||||
fixed_json = json_str.replace("'", '"')
|
|
||||||
try:
|
|
||||||
data = json.loads(fixed_json)
|
|
||||||
logger.warning("⚠️ Fixed JSON with quote replacement")
|
|
||||||
return data
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Last resort: log the problematic JSON
|
|
||||||
logger.error(f"❌ Problematic JSON: {json_str[:300]}")
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
raise ValueError("No JSON found in response")
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.error(f"❌ JSON parsing failed: {e}")
|
|
||||||
logger.error(f"Raw response preview: {response[:500]}")
|
|
||||||
return {
|
|
||||||
"error": f"JSON parsing failed: {str(e)}",
|
|
||||||
"confidence": 0.0,
|
|
||||||
"raw_response": response[:500]
|
|
||||||
}
|
|
||||||
|
|
||||||
def calculate_file_checksum(self, file_path: Path) -> str:
|
|
||||||
"""Calculate SHA256 checksum of file for duplicate detection"""
|
|
||||||
sha256 = hashlib.sha256()
|
|
||||||
with open(file_path, 'rb') as f:
|
|
||||||
while chunk := f.read(8192):
|
|
||||||
sha256.update(chunk)
|
|
||||||
checksum = sha256.hexdigest()
|
|
||||||
logger.info(f"📋 Calculated checksum: {checksum[:16]}... for {file_path.name}")
|
|
||||||
return checksum
|
|
||||||
|
|
||||||
async def _extract_text_from_file(self, file_path: Path) -> str:
|
|
||||||
"""Extract text from PDF, image, or text file"""
|
|
||||||
suffix = file_path.suffix.lower()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if suffix == '.pdf':
|
|
||||||
return await self._extract_text_from_pdf(file_path)
|
|
||||||
elif suffix in ['.png', '.jpg', '.jpeg']:
|
|
||||||
return await self._extract_text_from_image(file_path)
|
|
||||||
elif suffix in ['.txt', '.csv']:
|
|
||||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
||||||
return f.read()
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unsupported file type: {suffix}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Text extraction failed for {file_path.name}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _extract_text_from_pdf(self, file_path: Path) -> str:
|
|
||||||
"""Extract text from PDF using pdfplumber (better table/layout support)"""
|
|
||||||
try:
|
|
||||||
import pdfplumber
|
|
||||||
|
|
||||||
all_text = []
|
|
||||||
with pdfplumber.open(file_path) as pdf:
|
|
||||||
for page_num, page in enumerate(pdf.pages):
|
|
||||||
# Strategy: Use regular text extraction (includes tables)
|
|
||||||
# pdfplumber's extract_text() handles tables better than PyPDF2
|
|
||||||
page_text = page.extract_text(layout=True, x_tolerance=2, y_tolerance=2)
|
|
||||||
|
|
||||||
if page_text:
|
|
||||||
all_text.append(page_text)
|
|
||||||
|
|
||||||
text = "\\n".join(all_text)
|
|
||||||
logger.info(f"📄 Extracted {len(text)} chars from PDF with pdfplumber")
|
|
||||||
return text
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ PDF extraction failed: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _extract_text_from_image(self, file_path: Path) -> str:
|
|
||||||
"""Extract text from image using Tesseract OCR"""
|
|
||||||
try:
|
|
||||||
import pytesseract
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
image = Image.open(file_path)
|
|
||||||
|
|
||||||
# Use Danish + English for OCR
|
|
||||||
text = pytesseract.image_to_string(image, lang='dan+eng')
|
|
||||||
|
|
||||||
logger.info(f"🖼️ Extracted {len(text)} chars from image via OCR")
|
|
||||||
return text
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ OCR extraction failed: {e}")
|
|
||||||
# Fallback to English only
|
|
||||||
try:
|
|
||||||
text = pytesseract.image_to_string(Image.open(file_path), lang='eng')
|
|
||||||
logger.warning(f"⚠️ Fallback to English OCR: {len(text)} chars")
|
|
||||||
return text
|
|
||||||
except:
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _get_mime_type(self, file_path: Path) -> str:
|
|
||||||
"""Get MIME type from file extension"""
|
|
||||||
suffix = file_path.suffix.lower()
|
|
||||||
mime_types = {
|
|
||||||
'.pdf': 'application/pdf',
|
|
||||||
'.png': 'image/png',
|
|
||||||
'.jpg': 'image/jpeg',
|
|
||||||
'.jpeg': 'image/jpeg',
|
|
||||||
'.txt': 'text/plain',
|
|
||||||
'.csv': 'text/csv'
|
|
||||||
}
|
|
||||||
return mime_types.get(suffix, 'application/octet-stream')
|
|
||||||
|
|
||||||
async def quick_analysis_on_upload(self, pdf_text: str) -> Dict:
|
|
||||||
"""
|
|
||||||
Quick analysis when file is uploaded - extracts critical fields only:
|
|
||||||
- CVR number (to match vendor)
|
|
||||||
- Document type (invoice vs credit note)
|
|
||||||
- Invoice/credit note number
|
|
||||||
|
|
||||||
This runs BEFORE template matching for early vendor detection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pdf_text: Extracted text from PDF
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with cvr, document_type, document_number, vendor_id, vendor_name, is_own_invoice
|
|
||||||
"""
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
logger.info("⚡ Running quick analysis on upload...")
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"cvr": None,
|
|
||||||
"document_type": None, # 'invoice' or 'credit_note'
|
|
||||||
"document_number": None,
|
|
||||||
"vendor_id": None,
|
|
||||||
"vendor_name": None,
|
|
||||||
"is_own_invoice": False # True if this is an outgoing invoice (BMC's own CVR)
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1. FIND CVR NUMBER (8 digits)
|
|
||||||
# Look for patterns like "CVR: 12345678", "CVR-nr.: 12345678", "CVR 12345678"
|
|
||||||
# Important: Supplier invoices have BOTH buyer (BMC=29522790) and seller CVR
|
|
||||||
# We need the SELLER's CVR (not BMC's own)
|
|
||||||
|
|
||||||
cvr_patterns = [
|
|
||||||
r'CVR[:\-\s]*(\d{8})',
|
|
||||||
r'CVR[:\-\s]*nr\.?\s*(\d{8})',
|
|
||||||
r'CVR[:\-\s]*nummer\s*(\d{8})',
|
|
||||||
r'SE[:\-\s]*(\d{8})', # SE = Svensk CVR, men også brugt i DK
|
|
||||||
r'\b(\d{8})\b' # Fallback: any 8-digit number
|
|
||||||
]
|
|
||||||
|
|
||||||
# Find ALL CVR numbers in document
|
|
||||||
found_cvrs = []
|
|
||||||
for pattern in cvr_patterns:
|
|
||||||
matches = re.finditer(pattern, pdf_text, re.IGNORECASE)
|
|
||||||
for match in matches:
|
|
||||||
cvr_candidate = match.group(1)
|
|
||||||
# Validate it's a real CVR (starts with 1-4, not a random number)
|
|
||||||
if cvr_candidate[0] in '1234' and cvr_candidate not in found_cvrs:
|
|
||||||
found_cvrs.append(cvr_candidate)
|
|
||||||
|
|
||||||
# Remove BMC's own CVR from list (buyer CVR, not seller)
|
|
||||||
vendor_cvrs = [cvr for cvr in found_cvrs if cvr != settings.OWN_CVR]
|
|
||||||
|
|
||||||
if settings.OWN_CVR in found_cvrs:
|
|
||||||
# This is a proper invoice where BMC is the buyer
|
|
||||||
if len(vendor_cvrs) > 0:
|
|
||||||
# Found vendor CVR - use the first non-BMC CVR
|
|
||||||
result['cvr'] = vendor_cvrs[0]
|
|
||||||
logger.info(f"📋 Found vendor CVR: {vendor_cvrs[0]} (ignored BMC CVR: {settings.OWN_CVR})")
|
|
||||||
|
|
||||||
# Try to match vendor
|
|
||||||
vendor = self.match_vendor_by_cvr(vendor_cvrs[0])
|
|
||||||
if vendor:
|
|
||||||
result['vendor_id'] = vendor['id']
|
|
||||||
result['vendor_name'] = vendor['name']
|
|
||||||
else:
|
|
||||||
# Only BMC's CVR found = this is an outgoing invoice
|
|
||||||
result['is_own_invoice'] = True
|
|
||||||
result['cvr'] = settings.OWN_CVR
|
|
||||||
logger.warning(f"⚠️ OUTGOING INVOICE: Only BMC CVR found")
|
|
||||||
elif len(vendor_cvrs) > 0:
|
|
||||||
# No BMC CVR, but other CVR found - use first one
|
|
||||||
result['cvr'] = vendor_cvrs[0]
|
|
||||||
logger.info(f"📋 Found CVR: {vendor_cvrs[0]}")
|
|
||||||
|
|
||||||
vendor = self.match_vendor_by_cvr(vendor_cvrs[0])
|
|
||||||
if vendor:
|
|
||||||
result['vendor_id'] = vendor['id']
|
|
||||||
result['vendor_name'] = vendor['name']
|
|
||||||
|
|
||||||
# 2. DETECT DOCUMENT TYPE (Invoice vs Credit Note)
|
|
||||||
credit_keywords = [
|
|
||||||
'kreditnota', 'credit note', 'creditnote', 'kreditfaktura',
|
|
||||||
'refusion', 'tilbagebetaling', 'godtgørelse', 'tilbageførsel'
|
|
||||||
]
|
|
||||||
|
|
||||||
text_lower = pdf_text.lower()
|
|
||||||
is_credit_note = any(keyword in text_lower for keyword in credit_keywords)
|
|
||||||
|
|
||||||
if is_credit_note:
|
|
||||||
result['document_type'] = 'credit_note'
|
|
||||||
logger.info("📄 Document type: CREDIT NOTE")
|
|
||||||
else:
|
|
||||||
result['document_type'] = 'invoice'
|
|
||||||
logger.info("📄 Document type: INVOICE")
|
|
||||||
|
|
||||||
# 3. EXTRACT DOCUMENT NUMBER
|
|
||||||
# For invoices: "Faktura nr.", "Invoice number:", "Fakturanr."
|
|
||||||
# For credit notes: "Kreditnota nr.", "Credit note number:"
|
|
||||||
|
|
||||||
if result['document_type'] == 'credit_note':
|
|
||||||
number_patterns = [
|
|
||||||
r'kreditnota\s*(?:nr\.?|nummer)[:\s]*(\S+)',
|
|
||||||
r'credit\s*note\s*(?:no\.?|number)[:\s]*(\S+)',
|
|
||||||
r'kreditfaktura\s*(?:nr\.?|nummer)[:\s]*(\S+)',
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
number_patterns = [
|
|
||||||
r'faktura\s*(?:nr\.?|nummer)[:\s]*(\S+)',
|
|
||||||
r'invoice\s*(?:no\.?|number)[:\s]*(\S+)',
|
|
||||||
r'fakturanr\.?\s*[:\s]*(\S+)',
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in number_patterns:
|
|
||||||
match = re.search(pattern, pdf_text, re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
result['document_number'] = match.group(1).strip()
|
|
||||||
logger.info(f"🔢 Document number: {result['document_number']}")
|
|
||||||
break
|
|
||||||
|
|
||||||
logger.info(f"✅ Quick analysis complete: CVR={result['cvr']}, Type={result['document_type']}, Number={result['document_number']}, Vendor={result['vendor_name']}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def match_vendor_by_cvr(self, vendor_cvr: Optional[str]) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Match vendor from database using CVR number
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vendor_cvr: CVR number from extraction
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Vendor dict if found, None otherwise
|
|
||||||
"""
|
|
||||||
if not vendor_cvr:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Clean CVR (remove spaces, dashes)
|
|
||||||
cvr_clean = re.sub(r'[^0-9]', '', vendor_cvr)
|
|
||||||
|
|
||||||
if len(cvr_clean) != 8:
|
|
||||||
logger.warning(f"⚠️ Invalid CVR format: {vendor_cvr} (cleaned: {cvr_clean})")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Search vendors table
|
|
||||||
vendor = execute_query(
|
|
||||||
"SELECT * FROM vendors WHERE cvr_number = %s",
|
|
||||||
(cvr_clean,),
|
|
||||||
fetchone=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if vendor:
|
|
||||||
logger.info(f"✅ Matched vendor: {vendor['name']} (CVR: {cvr_clean})")
|
|
||||||
return vendor
|
|
||||||
else:
|
|
||||||
logger.info(f"⚠️ No vendor found with CVR: {cvr_clean}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
ollama_service = OllamaService()
|
|
||||||
@ -1,400 +0,0 @@
|
|||||||
"""
|
|
||||||
Supplier Invoice Template Service
|
|
||||||
Hybrid approach: invoice2data templates + custom regex templates
|
|
||||||
Inspired by OmniSync's invoice template system
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import logging
|
|
||||||
from typing import Dict, List, Optional, Tuple
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_insert, execute_update
|
|
||||||
from app.services.invoice2data_service import get_invoice2data_service
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateService:
|
|
||||||
"""Service for template-based invoice extraction"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.templates_cache = {}
|
|
||||||
self._initialized = False
|
|
||||||
self.invoice2data = None
|
|
||||||
|
|
||||||
def _ensure_loaded(self):
|
|
||||||
"""Lazy load templates on first use"""
|
|
||||||
if not self._initialized:
|
|
||||||
logger.info("🔄 Lazy loading templates...")
|
|
||||||
self._load_templates()
|
|
||||||
# Also load invoice2data templates
|
|
||||||
try:
|
|
||||||
self.invoice2data = get_invoice2data_service()
|
|
||||||
logger.info(f"✅ Invoice2Data service initialized")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"⚠️ Failed to load invoice2data: {e}")
|
|
||||||
self._initialized = True
|
|
||||||
|
|
||||||
def _load_templates(self):
|
|
||||||
"""Load all active templates into cache"""
|
|
||||||
try:
|
|
||||||
templates = execute_query(
|
|
||||||
"""SELECT t.*, v.name as vendor_name, v.cvr_number as vendor_cvr
|
|
||||||
FROM supplier_invoice_templates t
|
|
||||||
LEFT JOIN vendors v ON t.vendor_id = v.id
|
|
||||||
WHERE t.is_active = TRUE"""
|
|
||||||
)
|
|
||||||
|
|
||||||
if templates:
|
|
||||||
for template in templates:
|
|
||||||
self.templates_cache[template['template_id']] = template
|
|
||||||
logger.info(f"📚 Loaded {len(self.templates_cache)} active templates")
|
|
||||||
else:
|
|
||||||
logger.warning("⚠️ No templates found")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Failed to load templates: {e}")
|
|
||||||
|
|
||||||
def match_template(self, pdf_text: str) -> Tuple[Optional[int], float]:
|
|
||||||
"""
|
|
||||||
Find best matching template for PDF text
|
|
||||||
First tries invoice2data templates, then falls back to custom templates
|
|
||||||
Returns: (template_id, confidence_score)
|
|
||||||
"""
|
|
||||||
self._ensure_loaded() # Lazy load templates
|
|
||||||
|
|
||||||
# Try invoice2data templates first
|
|
||||||
if self.invoice2data:
|
|
||||||
try:
|
|
||||||
template_name = self.invoice2data.match_template(pdf_text)
|
|
||||||
if template_name:
|
|
||||||
logger.info(f"✅ Matched invoice2data template: {template_name}")
|
|
||||||
# Return special ID to indicate invoice2data template
|
|
||||||
return (-1, 1.0) # -1 = invoice2data, 100% confidence
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"⚠️ Invoice2data matching failed: {e}")
|
|
||||||
|
|
||||||
# Fallback to custom templates
|
|
||||||
logger.info(f"🔍 Matching against {len(self.templates_cache)} custom templates")
|
|
||||||
|
|
||||||
best_match = None
|
|
||||||
best_score = 0.0
|
|
||||||
pdf_text_lower = pdf_text.lower()
|
|
||||||
|
|
||||||
for template_id, template in self.templates_cache.items():
|
|
||||||
score = self._calculate_match_score(pdf_text_lower, template)
|
|
||||||
logger.debug(f" Template {template_id} ({template['template_name']}): {score:.2f}")
|
|
||||||
|
|
||||||
if score > best_score:
|
|
||||||
best_score = score
|
|
||||||
best_match = template_id
|
|
||||||
|
|
||||||
if best_match:
|
|
||||||
logger.info(f"✅ Matched template {best_match} ({self.templates_cache[best_match]['template_name']}) with {best_score:.0%} confidence")
|
|
||||||
else:
|
|
||||||
logger.info(f"⚠️ No template matched (best score: {best_score:.2f})")
|
|
||||||
|
|
||||||
return best_match, best_score
|
|
||||||
|
|
||||||
def _calculate_match_score(self, pdf_text: str, template: Dict) -> float:
|
|
||||||
"""Calculate match score based on detection patterns"""
|
|
||||||
score = 0.0
|
|
||||||
patterns = template.get('detection_patterns', [])
|
|
||||||
|
|
||||||
if not patterns:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
for pattern_obj in patterns:
|
|
||||||
pattern_type = pattern_obj.get('type')
|
|
||||||
weight = pattern_obj.get('weight', 0.5)
|
|
||||||
|
|
||||||
if pattern_type == 'text':
|
|
||||||
# Simple text search
|
|
||||||
pattern = pattern_obj.get('pattern', '').lower()
|
|
||||||
if pattern in pdf_text:
|
|
||||||
score += weight
|
|
||||||
|
|
||||||
elif pattern_type == 'cvr':
|
|
||||||
# CVR number match (exact)
|
|
||||||
cvr = str(pattern_obj.get('value', ''))
|
|
||||||
if cvr in pdf_text:
|
|
||||||
score += weight # CVR match is strong signal
|
|
||||||
|
|
||||||
elif pattern_type == 'regex':
|
|
||||||
# Regex pattern match
|
|
||||||
pattern = pattern_obj.get('pattern', '')
|
|
||||||
if re.search(pattern, pdf_text, re.IGNORECASE):
|
|
||||||
score += weight
|
|
||||||
|
|
||||||
return min(score, 1.0) # Cap at 100%
|
|
||||||
|
|
||||||
def extract_fields(self, pdf_text: str, template_id: int) -> Dict:
|
|
||||||
"""Extract invoice fields using template's regex patterns"""
|
|
||||||
self._ensure_loaded() # Lazy load templates
|
|
||||||
|
|
||||||
# Check if this is an invoice2data template
|
|
||||||
if template_id == -1:
|
|
||||||
if self.invoice2data:
|
|
||||||
try:
|
|
||||||
result = self.invoice2data.extract(pdf_text)
|
|
||||||
if result:
|
|
||||||
logger.info(f"✅ Extracted fields using invoice2data")
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Invoice2data extraction failed: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# Use custom template
|
|
||||||
template = self.templates_cache.get(template_id)
|
|
||||||
if not template:
|
|
||||||
logger.warning(f"⚠️ Template {template_id} not found in cache")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
field_mappings = template.get('field_mappings', {})
|
|
||||||
extracted = {}
|
|
||||||
|
|
||||||
for field_name, field_config in field_mappings.items():
|
|
||||||
pattern = field_config.get('pattern')
|
|
||||||
group = field_config.get('group', 1)
|
|
||||||
|
|
||||||
if not pattern:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Special handling for CVR to avoid extracting own CVR
|
|
||||||
if field_name == 'vendor_cvr':
|
|
||||||
from app.core.config import settings
|
|
||||||
own_cvr = getattr(settings, 'OWN_CVR', '29522790')
|
|
||||||
|
|
||||||
# Find ALL CVR matches
|
|
||||||
all_matches = list(re.finditer(pattern, pdf_text, re.IGNORECASE | re.MULTILINE))
|
|
||||||
found_cvrs = []
|
|
||||||
|
|
||||||
for match in all_matches:
|
|
||||||
if len(match.groups()) >= group:
|
|
||||||
cvr = match.group(group).strip()
|
|
||||||
found_cvrs.append(cvr)
|
|
||||||
|
|
||||||
# Filter out own CVR
|
|
||||||
vendor_cvrs = [cvr for cvr in found_cvrs if cvr != own_cvr]
|
|
||||||
|
|
||||||
if vendor_cvrs:
|
|
||||||
# Use first non-own CVR as vendor CVR
|
|
||||||
extracted[field_name] = vendor_cvrs[0]
|
|
||||||
logger.debug(f" ✓ {field_name}: {vendor_cvrs[0]} (filtered out own CVR: {own_cvr})")
|
|
||||||
else:
|
|
||||||
logger.warning(f" ⚠️ Only found own CVR ({own_cvr}), no vendor CVR found")
|
|
||||||
else:
|
|
||||||
# Normal extraction for other fields
|
|
||||||
match = re.search(pattern, pdf_text, re.IGNORECASE | re.MULTILINE)
|
|
||||||
if match and len(match.groups()) >= group:
|
|
||||||
value = match.group(group).strip()
|
|
||||||
extracted[field_name] = value
|
|
||||||
logger.debug(f" ✓ {field_name}: {value}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f" ✗ Failed to extract {field_name}: {e}")
|
|
||||||
|
|
||||||
return extracted
|
|
||||||
|
|
||||||
def extract_line_items(self, pdf_text: str, template_id: int) -> List[Dict]:
|
|
||||||
"""Extract invoice line items using template's line patterns"""
|
|
||||||
self._ensure_loaded() # Lazy load templates
|
|
||||||
|
|
||||||
template = self.templates_cache.get(template_id)
|
|
||||||
if not template:
|
|
||||||
logger.warning(f"⚠️ Template {template_id} not found in cache")
|
|
||||||
return []
|
|
||||||
|
|
||||||
field_mappings = template.get('field_mappings', {})
|
|
||||||
|
|
||||||
# Get line extraction config
|
|
||||||
lines_start = field_mappings.get('lines_start', {}).get('pattern')
|
|
||||||
lines_end = field_mappings.get('lines_end', {}).get('pattern')
|
|
||||||
line_pattern = field_mappings.get('line_item', {}).get('pattern')
|
|
||||||
line_fields = field_mappings.get('line_item', {}).get('fields', [])
|
|
||||||
|
|
||||||
if not line_pattern:
|
|
||||||
logger.debug("No line_item pattern configured")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Extract section between start and end markers
|
|
||||||
text_section = pdf_text
|
|
||||||
if lines_start:
|
|
||||||
try:
|
|
||||||
start_match = re.search(lines_start, pdf_text, re.IGNORECASE)
|
|
||||||
if start_match:
|
|
||||||
text_section = pdf_text[start_match.end():]
|
|
||||||
logger.debug(f"Found lines_start, section starts at position {start_match.end()}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to find lines_start: {e}")
|
|
||||||
|
|
||||||
if lines_end:
|
|
||||||
try:
|
|
||||||
end_match = re.search(lines_end, text_section, re.IGNORECASE)
|
|
||||||
if end_match:
|
|
||||||
text_section = text_section[:end_match.start()]
|
|
||||||
logger.debug(f"Found lines_end, section ends at position {end_match.start()}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to find lines_end: {e}")
|
|
||||||
|
|
||||||
# Try multiple extraction strategies
|
|
||||||
lines = self._extract_with_pattern(text_section, line_pattern, line_fields)
|
|
||||||
|
|
||||||
if not lines:
|
|
||||||
# Fallback: Try smart extraction for common formats
|
|
||||||
lines = self._smart_line_extraction(text_section, line_fields)
|
|
||||||
|
|
||||||
logger.info(f"📦 Extracted {len(lines)} line items")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
def _extract_with_pattern(self, text: str, pattern: str, field_names: List[str]) -> List[Dict]:
|
|
||||||
"""Extract lines using regex pattern"""
|
|
||||||
lines = []
|
|
||||||
try:
|
|
||||||
for match in re.finditer(pattern, text, re.MULTILINE):
|
|
||||||
line_data = {
|
|
||||||
'line_number': len(lines) + 1,
|
|
||||||
'raw_text': match.group(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Map captured groups to field names
|
|
||||||
for idx, field_name in enumerate(field_names, start=1):
|
|
||||||
if idx <= len(match.groups()):
|
|
||||||
line_data[field_name] = match.group(idx).strip()
|
|
||||||
|
|
||||||
lines.append(line_data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Pattern extraction failed: {e}")
|
|
||||||
|
|
||||||
return lines
|
|
||||||
|
|
||||||
def _smart_line_extraction(self, text: str, field_names: List[str]) -> List[Dict]:
|
|
||||||
"""
|
|
||||||
Multi-line extraction for ALSO-style invoices.
|
|
||||||
|
|
||||||
Format:
|
|
||||||
100 48023976 REFURB LENOVO ThinkPad P15 G1 Grde A
|
|
||||||
...metadata lines...
|
|
||||||
1ST 3.708,27 3.708,27
|
|
||||||
|
|
||||||
Combines data from description line + price line.
|
|
||||||
"""
|
|
||||||
lines_arr = text.split('\n')
|
|
||||||
items = []
|
|
||||||
i = 0
|
|
||||||
|
|
||||||
while i < len(lines_arr):
|
|
||||||
line = lines_arr[i].strip()
|
|
||||||
|
|
||||||
# Find position + varenr + beskrivelse linje
|
|
||||||
# Match: "100 48023976 REFURB LENOVO ThinkPad P15 G1 Grde A"
|
|
||||||
item_match = re.match(r'^(\d{1,3})\s+(\d{6,})\s+(.+)', line)
|
|
||||||
if item_match:
|
|
||||||
position = item_match.group(1)
|
|
||||||
item_number = item_match.group(2)
|
|
||||||
description = item_match.group(3).strip()
|
|
||||||
|
|
||||||
# Skip hvis det er en header
|
|
||||||
if re.search(r'(Position|Varenr|Beskrivelse|Antal|Pris|Total)', line, re.IGNORECASE):
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Find næste linje med antal+priser (inden for 10 linjer)
|
|
||||||
quantity = None
|
|
||||||
unit_price = None
|
|
||||||
total_price = None
|
|
||||||
vat_note = None # For "Omvendt betalingspligt" etc.
|
|
||||||
|
|
||||||
for j in range(i+1, min(i+10, len(lines_arr))):
|
|
||||||
price_line = lines_arr[j].strip()
|
|
||||||
# Match: "1ST 3.708,27 3.708,27"
|
|
||||||
price_match = re.match(r'^(\d+)\s*(?:ST|stk|pc|pcs)\s+([\d.,]+)\s+([\d.,]+)', price_line, re.IGNORECASE)
|
|
||||||
if price_match:
|
|
||||||
quantity = price_match.group(1)
|
|
||||||
unit_price = price_match.group(2).replace(',', '.')
|
|
||||||
total_price = price_match.group(3).replace(',', '.')
|
|
||||||
|
|
||||||
# Check next 3 lines for VAT markers
|
|
||||||
for k in range(j+1, min(j+4, len(lines_arr))):
|
|
||||||
vat_line = lines_arr[k].strip().lower()
|
|
||||||
if 'omvendt' in vat_line and 'betalingspligt' in vat_line:
|
|
||||||
vat_note = "reverse_charge"
|
|
||||||
logger.debug(f"⚠️ Found reverse charge marker for item {item_number}")
|
|
||||||
elif 'copydan' in vat_line:
|
|
||||||
vat_note = "copydan_included"
|
|
||||||
break
|
|
||||||
|
|
||||||
# Kun tilføj hvis vi fandt priser
|
|
||||||
if quantity and unit_price:
|
|
||||||
item_data = {
|
|
||||||
'line_number': len(items) + 1,
|
|
||||||
'position': position,
|
|
||||||
'item_number': item_number,
|
|
||||||
'description': description,
|
|
||||||
'quantity': quantity,
|
|
||||||
'unit_price': unit_price,
|
|
||||||
'total_price': total_price,
|
|
||||||
'raw_text': f"{line} ... {quantity}ST {unit_price} {total_price}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add VAT note if found
|
|
||||||
if vat_note:
|
|
||||||
item_data['vat_note'] = vat_note
|
|
||||||
|
|
||||||
items.append(item_data)
|
|
||||||
logger.info(f"✅ Multi-line item: {item_number} - {description[:30]}... ({quantity}ST @ {unit_price}){' [REVERSE CHARGE]' if vat_note == 'reverse_charge' else ''}")
|
|
||||||
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
if items:
|
|
||||||
logger.info(f"📦 Multi-line extraction found {len(items)} items")
|
|
||||||
else:
|
|
||||||
logger.warning("⚠️ Multi-line extraction found no items")
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
||||||
def log_usage(self, template_id: int, file_id: int, matched: bool,
|
|
||||||
confidence: float, fields: Dict):
|
|
||||||
"""Log template usage for statistics"""
|
|
||||||
import json
|
|
||||||
try:
|
|
||||||
execute_insert(
|
|
||||||
"""INSERT INTO template_usage_log
|
|
||||||
(template_id, file_id, matched, confidence, fields_extracted)
|
|
||||||
VALUES (%s, %s, %s, %s, %s)""",
|
|
||||||
(template_id, file_id, matched, confidence, json.dumps(fields))
|
|
||||||
)
|
|
||||||
|
|
||||||
if matched:
|
|
||||||
# Update template stats
|
|
||||||
execute_update(
|
|
||||||
"""UPDATE supplier_invoice_templates
|
|
||||||
SET usage_count = usage_count + 1,
|
|
||||||
success_count = success_count + 1,
|
|
||||||
last_used_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE template_id = %s""",
|
|
||||||
(template_id,)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Failed to log template usage: {e}")
|
|
||||||
|
|
||||||
def get_vendor_templates(self, vendor_id: int) -> List[Dict]:
|
|
||||||
"""Get all templates for a vendor"""
|
|
||||||
return execute_query(
|
|
||||||
"""SELECT * FROM supplier_invoice_templates
|
|
||||||
WHERE vendor_id = %s AND is_active = TRUE
|
|
||||||
ORDER BY usage_count DESC""",
|
|
||||||
(vendor_id,),
|
|
||||||
fetchall=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def reload_templates(self):
|
|
||||||
"""Reload templates from database"""
|
|
||||||
self.templates_cache = {}
|
|
||||||
self._initialized = False
|
|
||||||
self._ensure_loaded()
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
template_service = TemplateService()
|
|
||||||
@ -1,265 +0,0 @@
|
|||||||
"""
|
|
||||||
Settings and User Management API Router
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from typing import List, Optional, Dict
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from app.core.database import execute_query
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# Pydantic Models
|
|
||||||
class Setting(BaseModel):
|
|
||||||
id: int
|
|
||||||
key: str
|
|
||||||
value: Optional[str]
|
|
||||||
category: str
|
|
||||||
description: Optional[str]
|
|
||||||
value_type: str
|
|
||||||
is_public: bool
|
|
||||||
|
|
||||||
|
|
||||||
class SettingUpdate(BaseModel):
|
|
||||||
value: str
|
|
||||||
|
|
||||||
|
|
||||||
class User(BaseModel):
|
|
||||||
id: int
|
|
||||||
username: str
|
|
||||||
email: Optional[str]
|
|
||||||
full_name: Optional[str]
|
|
||||||
is_active: bool
|
|
||||||
last_login: Optional[str]
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
|
||||||
username: str
|
|
||||||
email: str
|
|
||||||
password: str
|
|
||||||
full_name: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
|
||||||
email: Optional[str] = None
|
|
||||||
full_name: Optional[str] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
# Settings Endpoints
|
|
||||||
@router.get("/settings", response_model=List[Setting], tags=["Settings"])
|
|
||||||
async def get_settings(category: Optional[str] = None):
|
|
||||||
"""Get all settings or filter by category"""
|
|
||||||
query = "SELECT * FROM settings"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if category:
|
|
||||||
query += " WHERE category = %s"
|
|
||||||
params.append(category)
|
|
||||||
|
|
||||||
query += " ORDER BY category, key"
|
|
||||||
result = execute_query(query, tuple(params) if params else None)
|
|
||||||
return result or []
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings/{key}", response_model=Setting, tags=["Settings"])
|
|
||||||
async def get_setting(key: str):
|
|
||||||
"""Get a specific setting by key"""
|
|
||||||
query = "SELECT * FROM settings WHERE key = %s"
|
|
||||||
result = execute_query(query, (key,))
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Setting not found")
|
|
||||||
|
|
||||||
return result[0]
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/settings/{key}", response_model=Setting, tags=["Settings"])
|
|
||||||
async def update_setting(key: str, setting: SettingUpdate):
|
|
||||||
"""Update a setting value"""
|
|
||||||
query = """
|
|
||||||
UPDATE settings
|
|
||||||
SET value = %s, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE key = %s
|
|
||||||
RETURNING *
|
|
||||||
"""
|
|
||||||
result = execute_query(query, (setting.value, key))
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Setting not found")
|
|
||||||
|
|
||||||
logger.info(f"✅ Updated setting: {key}")
|
|
||||||
return result[0]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings/categories/list", tags=["Settings"])
|
|
||||||
async def get_setting_categories():
|
|
||||||
"""Get list of all setting categories"""
|
|
||||||
query = "SELECT DISTINCT category FROM settings ORDER BY category"
|
|
||||||
result = execute_query(query)
|
|
||||||
return [row['category'] for row in result] if result else []
|
|
||||||
|
|
||||||
|
|
||||||
# User Management Endpoints
|
|
||||||
@router.get("/users", response_model=List[User], tags=["Users"])
|
|
||||||
async def get_users(is_active: Optional[bool] = None):
|
|
||||||
"""Get all users"""
|
|
||||||
query = "SELECT user_id as id, username, email, full_name, is_active, last_login, created_at FROM users"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if is_active is not None:
|
|
||||||
query += " WHERE is_active = %s"
|
|
||||||
params.append(is_active)
|
|
||||||
|
|
||||||
query += " ORDER BY username"
|
|
||||||
result = execute_query(query, tuple(params) if params else None)
|
|
||||||
return result or []
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/{user_id}", response_model=User, tags=["Users"])
|
|
||||||
async def get_user(user_id: int):
|
|
||||||
"""Get user by ID"""
|
|
||||||
query = "SELECT user_id as id, username, email, full_name, is_active, last_login, created_at FROM users WHERE user_id = %s"
|
|
||||||
result = execute_query(query, (user_id,))
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
return result[0]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users", response_model=User, tags=["Users"])
|
|
||||||
async def create_user(user: UserCreate):
|
|
||||||
"""Create a new user"""
|
|
||||||
# Check if username exists
|
|
||||||
existing = execute_query("SELECT user_id FROM users WHERE username = %s", (user.username,))
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(status_code=400, detail="Username already exists")
|
|
||||||
|
|
||||||
# Hash password (simple SHA256 for now - should use bcrypt in production)
|
|
||||||
import hashlib
|
|
||||||
password_hash = hashlib.sha256(user.password.encode()).hexdigest()
|
|
||||||
|
|
||||||
query = """
|
|
||||||
INSERT INTO users (username, email, password_hash, full_name, is_active)
|
|
||||||
VALUES (%s, %s, %s, %s, true)
|
|
||||||
RETURNING user_id as id, username, email, full_name, is_active, last_login, created_at
|
|
||||||
"""
|
|
||||||
result = execute_query(query, (user.username, user.email, password_hash, user.full_name))
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to create user")
|
|
||||||
|
|
||||||
logger.info(f"✅ Created user: {user.username}")
|
|
||||||
return result[0]
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/users/{user_id}", response_model=User, tags=["Users"])
|
|
||||||
async def update_user(user_id: int, user: UserUpdate):
|
|
||||||
"""Update user details"""
|
|
||||||
# Check if user exists
|
|
||||||
existing = execute_query("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
|
|
||||||
if not existing:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Build update query
|
|
||||||
update_fields = []
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if user.email is not None:
|
|
||||||
update_fields.append("email = %s")
|
|
||||||
params.append(user.email)
|
|
||||||
if user.full_name is not None:
|
|
||||||
update_fields.append("full_name = %s")
|
|
||||||
params.append(user.full_name)
|
|
||||||
if user.is_active is not None:
|
|
||||||
update_fields.append("is_active = %s")
|
|
||||||
params.append(user.is_active)
|
|
||||||
|
|
||||||
if not update_fields:
|
|
||||||
raise HTTPException(status_code=400, detail="No fields to update")
|
|
||||||
|
|
||||||
params.append(user_id)
|
|
||||||
query = f"""
|
|
||||||
UPDATE users
|
|
||||||
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE user_id = %s
|
|
||||||
RETURNING user_id as id, username, email, full_name, is_active, last_login, created_at
|
|
||||||
"""
|
|
||||||
|
|
||||||
result = execute_query(query, tuple(params))
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to update user")
|
|
||||||
|
|
||||||
logger.info(f"✅ Updated user: {user_id}")
|
|
||||||
return result[0]
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/users/{user_id}", tags=["Users"])
|
|
||||||
async def deactivate_user(user_id: int):
|
|
||||||
"""Deactivate a user (soft delete)"""
|
|
||||||
query = """
|
|
||||||
UPDATE users
|
|
||||||
SET is_active = false, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE user_id = %s
|
|
||||||
RETURNING user_id as id
|
|
||||||
"""
|
|
||||||
result = execute_query(query, (user_id,))
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
logger.info(f"✅ Deactivated user: {user_id}")
|
|
||||||
return {"message": "User deactivated successfully"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/{user_id}/reset-password", tags=["Users"])
|
|
||||||
async def reset_user_password(user_id: int, new_password: str):
|
|
||||||
"""Reset user password"""
|
|
||||||
import hashlib
|
|
||||||
password_hash = hashlib.sha256(new_password.encode()).hexdigest()
|
|
||||||
|
|
||||||
query = """
|
|
||||||
UPDATE users
|
|
||||||
SET password_hash = %s, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE user_id = %s
|
|
||||||
RETURNING user_id as id
|
|
||||||
"""
|
|
||||||
result = execute_query(query, (password_hash, user_id))
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
logger.info(f"✅ Reset password for user: {user_id}")
|
|
||||||
return {"message": "Password reset successfully"}
|
|
||||||
|
|
||||||
|
|
||||||
# AI Prompts Endpoint
|
|
||||||
@router.get("/ai-prompts", tags=["Settings"])
|
|
||||||
async def get_ai_prompts():
|
|
||||||
"""Get all AI prompts used in the system"""
|
|
||||||
from app.services.ollama_service import OllamaService
|
|
||||||
|
|
||||||
ollama_service = OllamaService()
|
|
||||||
|
|
||||||
prompts = {
|
|
||||||
"invoice_extraction": {
|
|
||||||
"name": "Faktura Udtrækning (Invoice Extraction)",
|
|
||||||
"description": "System prompt brugt til at udtrække data fra fakturaer og kreditnotaer via Ollama LLM",
|
|
||||||
"model": ollama_service.model,
|
|
||||||
"endpoint": ollama_service.endpoint,
|
|
||||||
"prompt": ollama_service._build_system_prompt(),
|
|
||||||
"parameters": {
|
|
||||||
"temperature": 0.1,
|
|
||||||
"top_p": 0.9,
|
|
||||||
"num_predict": 2000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return prompts
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
"""
|
|
||||||
Settings Frontend Views
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
templates = Jinja2Templates(directory="app")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings", response_class=HTMLResponse, tags=["Frontend"])
|
|
||||||
async def settings_page(request: Request):
|
|
||||||
"""Render settings page"""
|
|
||||||
return templates.TemplateResponse("settings/frontend/settings.html", {
|
|
||||||
"request": request,
|
|
||||||
"title": "Indstillinger"
|
|
||||||
})
|
|
||||||
@ -1,600 +0,0 @@
|
|||||||
{% extends "shared/frontend/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Indstillinger - BMC Hub{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.settings-nav {
|
|
||||||
position: sticky;
|
|
||||||
top: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-nav .nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-nav .nav-link:hover,
|
|
||||||
.settings-nav .nav-link.active {
|
|
||||||
background: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
border-left-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-group {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-item {
|
|
||||||
padding: 1.25rem;
|
|
||||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-info h6 {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-info small {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Indstillinger</h2>
|
|
||||||
<p class="text-muted mb-0">System konfiguration og brugerstyring</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<!-- Vertical Navigation -->
|
|
||||||
<div class="col-lg-2">
|
|
||||||
<div class="settings-nav">
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<a class="nav-link active" href="#company" data-tab="company">
|
|
||||||
<i class="bi bi-building me-2"></i>Firma
|
|
||||||
</a>
|
|
||||||
<a class="nav-link" href="#integrations" data-tab="integrations">
|
|
||||||
<i class="bi bi-plugin me-2"></i>Integrationer
|
|
||||||
</a>
|
|
||||||
<a class="nav-link" href="#notifications" data-tab="notifications">
|
|
||||||
<i class="bi bi-bell me-2"></i>Notifikationer
|
|
||||||
</a>
|
|
||||||
<a class="nav-link" href="#users" data-tab="users">
|
|
||||||
<i class="bi bi-people me-2"></i>Brugere
|
|
||||||
</a>
|
|
||||||
<a class="nav-link" href="#ai-prompts" data-tab="ai-prompts">
|
|
||||||
<i class="bi bi-robot me-2"></i>AI Prompts
|
|
||||||
</a>
|
|
||||||
<a class="nav-link" href="#system" data-tab="system">
|
|
||||||
<i class="bi bi-gear me-2"></i>System
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Area -->
|
|
||||||
<div class="col-lg-10">
|
|
||||||
<div class="tab-content">
|
|
||||||
<!-- Company Settings -->
|
|
||||||
<div class="tab-pane fade show active" id="company">
|
|
||||||
<div class="card p-4">
|
|
||||||
<h5 class="mb-4 fw-bold">Firma Oplysninger</h5>
|
|
||||||
<div id="companySettings">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Integrations -->
|
|
||||||
<div class="tab-pane fade" id="integrations">
|
|
||||||
<div class="card p-4 mb-4">
|
|
||||||
<h5 class="mb-4 fw-bold">vTiger CRM</h5>
|
|
||||||
<div id="vtigerSettings">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card p-4">
|
|
||||||
<h5 class="mb-4 fw-bold">e-conomic</h5>
|
|
||||||
<div id="economicSettings">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notifications -->
|
|
||||||
<div class="tab-pane fade" id="notifications">
|
|
||||||
<div class="card p-4">
|
|
||||||
<h5 class="mb-4 fw-bold">Notifikation Indstillinger</h5>
|
|
||||||
<div id="notificationSettings">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Users -->
|
|
||||||
<div class="tab-pane fade" id="users">
|
|
||||||
<div class="card p-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h5 class="mb-0 fw-bold">Brugerstyring</h5>
|
|
||||||
<button class="btn btn-primary" onclick="showCreateUserModal()">
|
|
||||||
<i class="bi bi-plus-lg me-2"></i>Opret Bruger
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Bruger</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Sidst Login</th>
|
|
||||||
<th>Oprettet</th>
|
|
||||||
<th class="text-end">Handlinger</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="usersTableBody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI Prompts -->
|
|
||||||
<div class="tab-pane fade" id="ai-prompts">
|
|
||||||
<div class="card p-4">
|
|
||||||
<h5 class="mb-4 fw-bold">
|
|
||||||
<i class="bi bi-robot me-2"></i>AI System Prompts
|
|
||||||
</h5>
|
|
||||||
<p class="text-muted mb-4">
|
|
||||||
Her kan du se de prompts der bruges til forskellige AI funktioner i systemet.
|
|
||||||
</p>
|
|
||||||
<div id="aiPromptsContent">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- System Settings -->
|
|
||||||
<div class="tab-pane fade" id="system">
|
|
||||||
<div class="card p-4">
|
|
||||||
<h5 class="mb-4 fw-bold">System Indstillinger</h5>
|
|
||||||
<div id="systemSettings">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create User Modal -->
|
|
||||||
<div class="modal fade" id="createUserModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Opret Ny Bruger</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="createUserForm">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Brugernavn *</label>
|
|
||||||
<input type="text" class="form-control" id="newUsername" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Email *</label>
|
|
||||||
<input type="email" class="form-control" id="newEmail" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Fulde Navn</label>
|
|
||||||
<input type="text" class="form-control" id="newFullName">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Adgangskode *</label>
|
|
||||||
<input type="password" class="form-control" id="newPassword" required>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</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="createUser()">Opret Bruger</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
let allSettings = [];
|
|
||||||
|
|
||||||
async function loadSettings() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/settings');
|
|
||||||
allSettings = await response.json();
|
|
||||||
displaySettingsByCategory();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading settings:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displaySettingsByCategory() {
|
|
||||||
const categories = {
|
|
||||||
company: ['company_name', 'company_cvr', 'company_email', 'company_phone', 'company_address'],
|
|
||||||
integrations: ['vtiger_enabled', 'vtiger_url', 'vtiger_username', 'economic_enabled', 'economic_app_secret', 'economic_agreement_token'],
|
|
||||||
notifications: ['email_notifications'],
|
|
||||||
system: ['system_timezone']
|
|
||||||
};
|
|
||||||
|
|
||||||
// Company settings
|
|
||||||
displaySettings('companySettings', categories.company);
|
|
||||||
|
|
||||||
// vTiger settings
|
|
||||||
displaySettings('vtigerSettings', ['vtiger_enabled', 'vtiger_url', 'vtiger_username']);
|
|
||||||
|
|
||||||
// Economic settings
|
|
||||||
displaySettings('economicSettings', ['economic_enabled', 'economic_app_secret', 'economic_agreement_token']);
|
|
||||||
|
|
||||||
// Notification settings
|
|
||||||
displaySettings('notificationSettings', categories.notifications);
|
|
||||||
|
|
||||||
// System settings
|
|
||||||
displaySettings('systemSettings', categories.system);
|
|
||||||
}
|
|
||||||
|
|
||||||
function displaySettings(containerId, keys) {
|
|
||||||
const container = document.getElementById(containerId);
|
|
||||||
const settings = allSettings.filter(s => keys.includes(s.key));
|
|
||||||
|
|
||||||
if (settings.length === 0) {
|
|
||||||
container.innerHTML = '<p class="text-muted">Ingen indstillinger tilgængelige</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = settings.map(setting => {
|
|
||||||
const inputId = `setting_${setting.key}`;
|
|
||||||
let inputHtml = '';
|
|
||||||
|
|
||||||
if (setting.value_type === 'boolean') {
|
|
||||||
inputHtml = `
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="${inputId}"
|
|
||||||
${setting.value === 'true' ? 'checked' : ''}
|
|
||||||
onchange="updateSetting('${setting.key}', this.checked ? 'true' : 'false')">
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else if (setting.key.includes('password') || setting.key.includes('secret') || setting.key.includes('token')) {
|
|
||||||
inputHtml = `
|
|
||||||
<input type="password" class="form-control" id="${inputId}"
|
|
||||||
value="${setting.value || ''}"
|
|
||||||
onblur="updateSetting('${setting.key}', this.value)"
|
|
||||||
style="max-width: 300px;">
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
inputHtml = `
|
|
||||||
<input type="text" class="form-control" id="${inputId}"
|
|
||||||
value="${setting.value || ''}"
|
|
||||||
onblur="updateSetting('${setting.key}', this.value)"
|
|
||||||
style="max-width: 300px;">
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="setting-info">
|
|
||||||
<h6>${setting.description || setting.key}</h6>
|
|
||||||
<small><code>${setting.key}</code></small>
|
|
||||||
</div>
|
|
||||||
<div>${inputHtml}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateSetting(key, value) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/settings/${key}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ value })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Show success toast
|
|
||||||
console.log(`✅ Updated ${key}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating setting:', error);
|
|
||||||
alert('Kunne ikke opdatere indstilling');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsers() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/users');
|
|
||||||
const users = await response.json();
|
|
||||||
displayUsers(users);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading users:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayUsers(users) {
|
|
||||||
const tbody = document.getElementById('usersTableBody');
|
|
||||||
|
|
||||||
if (users.length === 0) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-5">Ingen brugere fundet</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = users.map(user => `
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<div class="user-avatar">${getInitials(user.username)}</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-semibold">${escapeHtml(user.username)}</div>
|
|
||||||
${user.full_name ? `<small class="text-muted">${escapeHtml(user.full_name)}</small>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>${user.email ? escapeHtml(user.email) : '<span class="text-muted">-</span>'}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge ${user.is_active ? 'bg-success' : 'bg-secondary'}">
|
|
||||||
${user.is_active ? 'Aktiv' : 'Inaktiv'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>${user.last_login ? formatDate(user.last_login) : '<span class="text-muted">Aldrig</span>'}</td>
|
|
||||||
<td>${formatDate(user.created_at)}</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<button class="btn btn-light" onclick="resetPassword(${user.id})" title="Nulstil adgangskode">
|
|
||||||
<i class="bi bi-key"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-light" onclick="toggleUserActive(${user.id}, ${!user.is_active})"
|
|
||||||
title="${user.is_active ? 'Deaktiver' : 'Aktiver'}">
|
|
||||||
<i class="bi bi-${user.is_active ? 'pause' : 'play'}-circle"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCreateUserModal() {
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('createUserModal'));
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createUser() {
|
|
||||||
const user = {
|
|
||||||
username: document.getElementById('newUsername').value,
|
|
||||||
email: document.getElementById('newEmail').value,
|
|
||||||
full_name: document.getElementById('newFullName').value || null,
|
|
||||||
password: document.getElementById('newPassword').value
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/users', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(user)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('createUserModal')).hide();
|
|
||||||
document.getElementById('createUserForm').reset();
|
|
||||||
loadUsers();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(error.detail || 'Kunne ikke oprette bruger');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating user:', error);
|
|
||||||
alert('Kunne ikke oprette bruger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleUserActive(userId, isActive) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/users/${userId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ is_active: isActive })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
loadUsers();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling user:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetPassword(userId) {
|
|
||||||
const newPassword = prompt('Indtast ny adgangskode:');
|
|
||||||
if (!newPassword) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/users/${userId}/reset-password?new_password=${newPassword}`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
alert('Adgangskode nulstillet!');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error resetting password:', error);
|
|
||||||
alert('Kunne ikke nulstille adgangskode');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitials(name) {
|
|
||||||
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load AI Prompts
|
|
||||||
async function loadAIPrompts() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/ai-prompts');
|
|
||||||
const prompts = await response.json();
|
|
||||||
|
|
||||||
const container = document.getElementById('aiPromptsContent');
|
|
||||||
container.innerHTML = Object.entries(prompts).map(([key, prompt]) => `
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header bg-light">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h6 class="mb-1 fw-bold">${escapeHtml(prompt.name)}</h6>
|
|
||||||
<small class="text-muted">${escapeHtml(prompt.description)}</small>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="copyPrompt('${key}')">
|
|
||||||
<i class="bi bi-clipboard me-1"></i>Kopier
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<small class="text-muted">Model:</small>
|
|
||||||
<div><code>${escapeHtml(prompt.model)}</code></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<small class="text-muted">Endpoint:</small>
|
|
||||||
<div><code>${escapeHtml(prompt.endpoint)}</code></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<small class="text-muted">Parametre:</small>
|
|
||||||
<div><code>${JSON.stringify(prompt.parameters)}</code></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<small class="text-muted fw-bold d-block mb-2">System Prompt:</small>
|
|
||||||
<pre id="prompt_${key}" class="border rounded p-3 bg-light" style="max-height: 400px; overflow-y: auto; font-size: 0.85rem; white-space: pre-wrap;">${escapeHtml(prompt.prompt)}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading AI prompts:', error);
|
|
||||||
document.getElementById('aiPromptsContent').innerHTML =
|
|
||||||
'<div class="alert alert-danger">Kunne ikke indlæse AI prompts</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyPrompt(key) {
|
|
||||||
const promptElement = document.getElementById(`prompt_${key}`);
|
|
||||||
const text = promptElement.textContent;
|
|
||||||
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
|
||||||
// Show success feedback
|
|
||||||
const btn = event.target.closest('button');
|
|
||||||
const originalHtml = btn.innerHTML;
|
|
||||||
btn.innerHTML = '<i class="bi bi-check me-1"></i>Kopieret!';
|
|
||||||
btn.classList.remove('btn-outline-primary');
|
|
||||||
btn.classList.add('btn-success');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
btn.innerHTML = originalHtml;
|
|
||||||
btn.classList.remove('btn-success');
|
|
||||||
btn.classList.add('btn-outline-primary');
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString) {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('da-DK', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab navigation
|
|
||||||
document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const tab = link.dataset.tab;
|
|
||||||
|
|
||||||
// Update nav
|
|
||||||
document.querySelectorAll('.settings-nav .nav-link').forEach(l => l.classList.remove('active'));
|
|
||||||
link.classList.add('active');
|
|
||||||
|
|
||||||
// Update content
|
|
||||||
document.querySelectorAll('.tab-pane').forEach(pane => {
|
|
||||||
pane.classList.remove('show', 'active');
|
|
||||||
});
|
|
||||||
document.getElementById(tab).classList.add('show', 'active');
|
|
||||||
|
|
||||||
// Load data for tab
|
|
||||||
if (tab === 'users') {
|
|
||||||
loadUsers();
|
|
||||||
} else if (tab === 'ai-prompts') {
|
|
||||||
loadAIPrompts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load on page ready
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
loadSettings();
|
|
||||||
loadUsers();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,763 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="da" data-bs-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{% block title %}BMC Hub{% endblock %}</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-body: #f8f9fa;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
--text-primary: #2c3e50;
|
|
||||||
--text-secondary: #6c757d;
|
|
||||||
--accent: #0f4c75;
|
|
||||||
--accent-light: #eef2f5;
|
|
||||||
--border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="dark"] {
|
|
||||||
--bg-body: #212529;
|
|
||||||
--bg-card: #2c3034;
|
|
||||||
--text-primary: #f8f9fa;
|
|
||||||
--text-secondary: #adb5bd;
|
|
||||||
--accent: #3d8bfd; /* Lighter blue for dark mode */
|
|
||||||
--accent-light: #373b3e;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
padding-top: 80px;
|
|
||||||
transition: background-color 0.3s, color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
background: var(--bg-card);
|
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-bottom: 1px solid rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.6rem 1.2rem !important;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
|
||||||
transition: transform 0.2s, background-color 0.3s;
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card h3 {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card p {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
padding: 0.6rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: #0a3655;
|
|
||||||
border-color: #0a3655;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-search {
|
|
||||||
background: var(--bg-body);
|
|
||||||
border: 1px solid rgba(0,0,0,0.1);
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
width: 300px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-search:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu {
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:hover {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% block extra_css %}{% endblock %}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-lg fixed-top">
|
|
||||||
<div class="container-fluid px-4">
|
|
||||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
|
||||||
<div class="bg-primary text-white rounded p-1 me-2 d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; background-color: var(--accent) !important;">
|
|
||||||
<i class="bi bi-hdd-network-fill" style="font-size: 16px;"></i>
|
|
||||||
</div>
|
|
||||||
BMC Hub
|
|
||||||
</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav mx-auto">
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-people me-2"></i>CRM
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="/customers">Kunder</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="/contacts">Kontakter</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="/vendors">Leverandører</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Leads</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-headset me-2"></i>Support
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Sager</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Ny Sag</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-cart3 me-2"></i>Salg
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Tilbud</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Ordre</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Produkter</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Pipeline</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-currency-dollar me-2"></i>Økonomi
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Fakturaer</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Kassekladde</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Betalinger</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<button class="btn btn-light rounded-circle border-0" id="darkModeToggle" style="background: var(--accent-light); color: var(--accent);">
|
|
||||||
<i class="bi bi-moon-fill"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-light rounded-circle border-0" style="background: var(--accent-light); color: var(--accent);"><i class="bi bi-bell"></i></button>
|
|
||||||
<div class="dropdown">
|
|
||||||
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="dropdown">
|
|
||||||
<img src="https://ui-avatars.com/api/?name=CT&background=0f4c75&color=fff" class="rounded-circle me-2" width="32">
|
|
||||||
<span class="small fw-bold" style="color: var(--text-primary)">Christian</span>
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Profil</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item py-2 text-danger" href="#">Log ud</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Global Search Modal (Cmd+K) -->
|
|
||||||
<div class="modal fade" id="globalSearchModal" tabindex="-1" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-dialog-centered" style="max-width: 85vw; width: 85vw;">
|
|
||||||
<div class="modal-content" style="border: none; border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); height: 85vh;">
|
|
||||||
<div class="modal-body p-0 d-flex flex-column" style="height: 100%;">
|
|
||||||
<div class="p-4 border-bottom" style="background: var(--bg-card);">
|
|
||||||
<div class="position-relative">
|
|
||||||
<i class="bi bi-search position-absolute" style="left: 20px; top: 50%; transform: translateY(-50%); font-size: 1.5rem; color: var(--text-secondary);"></i>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="globalSearchInput"
|
|
||||||
class="form-control form-control-lg ps-5"
|
|
||||||
placeholder="Søg efter kunder, sager, produkter... (tryk Esc for at lukke)"
|
|
||||||
style="border: none; background: var(--bg-body); font-size: 1.25rem; padding: 1rem 1rem 1rem 4rem; border-radius: 12px;"
|
|
||||||
autofocus
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2 mt-3">
|
|
||||||
<span class="badge bg-secondary bg-opacity-10 text-secondary">⌘K for at åbne</span>
|
|
||||||
<span class="badge bg-secondary bg-opacity-10 text-secondary">ESC for at lukke</span>
|
|
||||||
<span class="badge bg-secondary bg-opacity-10 text-secondary">↑↓ for at navigere</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-0 flex-grow-1" style="overflow-y: auto;">
|
|
||||||
<!-- Search Results & Workflows (3/4 width) -->
|
|
||||||
<div class="col-lg-9 p-4" style="border-right: 1px solid rgba(0,0,0,0.1);">
|
|
||||||
<!-- Contextual Workflows Section -->
|
|
||||||
<div id="workflowActions" style="display: none;" class="mb-4">
|
|
||||||
<h6 class="text-muted text-uppercase small fw-bold mb-3">
|
|
||||||
<i class="bi bi-lightning-charge me-2"></i>Hurtige Handlinger
|
|
||||||
</h6>
|
|
||||||
<div id="workflowButtons" class="d-flex flex-wrap gap-2">
|
|
||||||
<!-- Dynamic workflow buttons -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="searchResults">
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div id="emptyState" class="text-center py-5">
|
|
||||||
<i class="bi bi-search text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
|
|
||||||
<p class="text-muted mt-3">Tryk <kbd>⌘K</kbd> eller begynd at skrive...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CRM Results -->
|
|
||||||
<div id="crmResults" class="result-section mb-4" style="display: none;">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
|
||||||
<i class="bi bi-people me-2"></i>CRM
|
|
||||||
</h6>
|
|
||||||
<a href="/crm/workflow" class="btn btn-sm btn-outline-primary">
|
|
||||||
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="result-items">
|
|
||||||
<!-- Dynamic results will be inserted here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Support Results -->
|
|
||||||
<div id="supportResults" class="result-section mb-4" style="display: none;">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
|
||||||
<i class="bi bi-headset me-2"></i>Support
|
|
||||||
</h6>
|
|
||||||
<a href="/support/workflow" class="btn btn-sm btn-outline-primary">
|
|
||||||
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="result-items">
|
|
||||||
<!-- Dynamic results will be inserted here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sales Results -->
|
|
||||||
<div id="salesResults" class="result-section mb-4" style="display: none;">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
|
||||||
<i class="bi bi-cart3 me-2"></i>Salg
|
|
||||||
</h6>
|
|
||||||
<a href="/sales/workflow" class="btn btn-sm btn-outline-primary">
|
|
||||||
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="result-items">
|
|
||||||
<!-- Dynamic results will be inserted here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Finance Results -->
|
|
||||||
<div id="financeResults" class="result-section mb-4" style="display: none;">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
|
||||||
<i class="bi bi-currency-dollar me-2"></i>Økonomi
|
|
||||||
</h6>
|
|
||||||
<a href="/finance/workflow" class="btn btn-sm btn-outline-primary">
|
|
||||||
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="result-items">
|
|
||||||
<!-- Dynamic results will be inserted here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Live Boxes Sidebar (1/4 width) -->
|
|
||||||
<div class="col-lg-3 p-3" style="background: var(--bg-body); overflow-y: auto;">
|
|
||||||
<!-- Recent Activity Section -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<h6 class="text-uppercase small fw-bold mb-3" style="color: var(--text-primary);">
|
|
||||||
<i class="bi bi-clock-history me-2"></i>Seneste Aktivitet
|
|
||||||
</h6>
|
|
||||||
<div id="recentActivityList">
|
|
||||||
<!-- Dynamic activity items -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="my-3">
|
|
||||||
|
|
||||||
<!-- Sales Box -->
|
|
||||||
<div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
|
|
||||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
||||||
<h6 class="text-uppercase small fw-bold mb-0">
|
|
||||||
<i class="bi bi-cart3 me-2"></i>Sales
|
|
||||||
</h6>
|
|
||||||
<i class="bi bi-arrow-up-right"></i>
|
|
||||||
</div>
|
|
||||||
<div id="salesBox">
|
|
||||||
<div class="mb-2">
|
|
||||||
<p class="mb-0 small opacity-75">Aktive ordrer</p>
|
|
||||||
<h4 class="mb-0 fw-bold">-</h4>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<p class="mb-0 small opacity-75">Månedens salg</p>
|
|
||||||
<h5 class="mb-0 fw-bold">- kr</h5>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Support Box -->
|
|
||||||
<div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white;">
|
|
||||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
||||||
<h6 class="text-uppercase small fw-bold mb-0">
|
|
||||||
<i class="bi bi-headset me-2"></i>Support
|
|
||||||
</h6>
|
|
||||||
<i class="bi bi-arrow-up-right"></i>
|
|
||||||
</div>
|
|
||||||
<div id="supportBox">
|
|
||||||
<div class="mb-2">
|
|
||||||
<p class="mb-0 small opacity-75">Åbne sager</p>
|
|
||||||
<h4 class="mb-0 fw-bold">-</h4>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<p class="mb-0 small opacity-75">Gns. svartid</p>
|
|
||||||
<h5 class="mb-0 fw-bold">- min</h5>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Økonomi Box -->
|
|
||||||
<div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color: white;">
|
|
||||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
||||||
<h6 class="text-uppercase small fw-bold mb-0">
|
|
||||||
<i class="bi bi-currency-dollar me-2"></i>Økonomi
|
|
||||||
</h6>
|
|
||||||
<i class="bi bi-arrow-up-right"></i>
|
|
||||||
</div>
|
|
||||||
<div id="financeBox">
|
|
||||||
<div class="mb-2">
|
|
||||||
<p class="mb-0 small opacity-75">Ubetalte fakturaer</p>
|
|
||||||
<h4 class="mb-0 fw-bold">-</h4>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<p class="mb-0 small opacity-75">Samlet beløb</p>
|
|
||||||
<h5 class="mb-0 fw-bold">- kr</h5>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container-fluid px-4 py-4">
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script>
|
|
||||||
// Dark Mode Toggle Logic
|
|
||||||
const darkModeToggle = document.getElementById('darkModeToggle');
|
|
||||||
const htmlElement = document.documentElement;
|
|
||||||
const icon = darkModeToggle.querySelector('i');
|
|
||||||
|
|
||||||
// Check local storage
|
|
||||||
if (localStorage.getItem('theme') === 'dark') {
|
|
||||||
htmlElement.setAttribute('data-bs-theme', 'dark');
|
|
||||||
icon.classList.replace('bi-moon-fill', 'bi-sun-fill');
|
|
||||||
}
|
|
||||||
|
|
||||||
darkModeToggle.addEventListener('click', () => {
|
|
||||||
if (htmlElement.getAttribute('data-bs-theme') === 'dark') {
|
|
||||||
htmlElement.setAttribute('data-bs-theme', 'light');
|
|
||||||
localStorage.setItem('theme', 'light');
|
|
||||||
icon.classList.replace('bi-sun-fill', 'bi-moon-fill');
|
|
||||||
} else {
|
|
||||||
htmlElement.setAttribute('data-bs-theme', 'dark');
|
|
||||||
localStorage.setItem('theme', 'dark');
|
|
||||||
icon.classList.replace('bi-moon-fill', 'bi-sun-fill');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Global Search Modal (Cmd+K) - Initialize after DOM is ready
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
|
|
||||||
const searchInput = document.getElementById('globalSearchInput');
|
|
||||||
|
|
||||||
// Keyboard shortcut: Cmd+K or Ctrl+K
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
||||||
e.preventDefault();
|
|
||||||
console.log('Cmd+K pressed - opening search modal'); // Debug
|
|
||||||
searchModal.show();
|
|
||||||
setTimeout(() => {
|
|
||||||
searchInput.focus();
|
|
||||||
loadLiveStats();
|
|
||||||
loadRecentActivity();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ESC to close
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
searchModal.hide();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset search when modal is closed
|
|
||||||
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
|
|
||||||
searchInput.value = '';
|
|
||||||
selectedEntity = null;
|
|
||||||
document.getElementById('emptyState').style.display = 'block';
|
|
||||||
document.getElementById('workflowActions').style.display = 'none';
|
|
||||||
document.getElementById('crmResults').style.display = 'none';
|
|
||||||
document.getElementById('supportResults').style.display = 'none';
|
|
||||||
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
|
||||||
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load live statistics for the three boxes
|
|
||||||
async function loadLiveStats() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/dashboard/live-stats');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Update Sales Box
|
|
||||||
const salesBox = document.getElementById('salesBox');
|
|
||||||
salesBox.innerHTML = `
|
|
||||||
<div class="mb-2">
|
|
||||||
<p class="mb-0 small opacity-75">Aktive ordrer</p>
|
|
||||||
<h4 class="mb-0 fw-bold">${data.sales.active_orders}</h4>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<p class="mb-0 small opacity-75">Månedens salg</p>
|
|
||||||
<h5 class="mb-0 fw-bold">${data.sales.monthly_sales.toLocaleString('da-DK')} kr</h5>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Update Support Box
|
|
||||||
const supportBox = document.getElementById('supportBox');
|
|
||||||
supportBox.innerHTML = `
|
|
||||||
<div class="mb-2">
|
|
||||||
<p class="mb-0 small opacity-75">Åbne sager</p>
|
|
||||||
<h4 class="mb-0 fw-bold">${data.support.open_tickets}</h4>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<p class="mb-0 small opacity-75">Gns. svartid</p>
|
|
||||||
<h5 class="mb-0 fw-bold">${data.support.avg_response_time} min</h5>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Update Finance Box
|
|
||||||
const financeBox = document.getElementById('financeBox');
|
|
||||||
financeBox.innerHTML = `
|
|
||||||
<div class="mb-2">
|
|
||||||
<p class="mb-0 small opacity-75">Ubetalte fakturaer</p>
|
|
||||||
<h4 class="mb-0 fw-bold">${data.finance.unpaid_invoices_count}</h4>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<p class="mb-0 small opacity-75">Samlet beløb</p>
|
|
||||||
<h5 class="mb-0 fw-bold">${data.finance.unpaid_invoices_amount.toLocaleString('da-DK')} kr</h5>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading live stats:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load recent activity
|
|
||||||
async function loadRecentActivity() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/dashboard/recent-activity');
|
|
||||||
const activities = await response.json();
|
|
||||||
|
|
||||||
const activityList = document.getElementById('recentActivityList');
|
|
||||||
|
|
||||||
if (activities.length === 0) {
|
|
||||||
activityList.innerHTML = '<p class="small text-muted">Ingen nylig aktivitet</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
activityList.innerHTML = activities.map(activity => {
|
|
||||||
const timeAgo = getTimeAgo(new Date(activity.created_at));
|
|
||||||
const label = activity.activity_type === 'customer' ? 'Kunde' :
|
|
||||||
activity.activity_type === 'contact' ? 'Kontakt' : 'Leverandør';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="activity-item mb-2 p-2 rounded" style="background: var(--bg-card); border-left: 3px solid var(--accent);">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="${activity.icon} text-${activity.color} me-2" style="font-size: 1.1rem;"></i>
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<p class="mb-0 small fw-bold" style="color: var(--text-primary);">${activity.name}</p>
|
|
||||||
<p class="mb-0 small text-muted">${label} • ${timeAgo}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading recent activity:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to format time ago
|
|
||||||
function getTimeAgo(date) {
|
|
||||||
const seconds = Math.floor((new Date() - date) / 1000);
|
|
||||||
|
|
||||||
let interval = seconds / 31536000;
|
|
||||||
if (interval > 1) return Math.floor(interval) + ' år siden';
|
|
||||||
|
|
||||||
interval = seconds / 2592000;
|
|
||||||
if (interval > 1) return Math.floor(interval) + ' mdr siden';
|
|
||||||
|
|
||||||
interval = seconds / 86400;
|
|
||||||
if (interval > 1) return Math.floor(interval) + ' dage siden';
|
|
||||||
|
|
||||||
interval = seconds / 3600;
|
|
||||||
if (interval > 1) return Math.floor(interval) + ' timer siden';
|
|
||||||
|
|
||||||
interval = seconds / 60;
|
|
||||||
if (interval > 1) return Math.floor(interval) + ' min siden';
|
|
||||||
|
|
||||||
return 'Lige nu';
|
|
||||||
}
|
|
||||||
|
|
||||||
let searchTimeout;
|
|
||||||
let selectedEntity = null;
|
|
||||||
|
|
||||||
// Workflow definitions per entity type
|
|
||||||
const workflows = {
|
|
||||||
customer: [
|
|
||||||
{ label: 'Opret ordre', icon: 'cart-plus', action: (data) => window.location.href = `/orders/new?customer=${data.id}` },
|
|
||||||
{ label: 'Opret sag', icon: 'ticket-detailed', action: (data) => window.location.href = `/tickets/new?customer=${data.id}` },
|
|
||||||
{ label: 'Ring til kontakt', icon: 'telephone', action: (data) => alert('Ring til: ' + (data.phone || 'Intet telefonnummer')) },
|
|
||||||
{ label: 'Vis kunde', icon: 'eye', action: (data) => window.location.href = `/customers/${data.id}` }
|
|
||||||
],
|
|
||||||
contact: [
|
|
||||||
{ label: 'Ring op', icon: 'telephone', action: (data) => alert('Ring til: ' + (data.mobile_phone || data.phone || 'Intet telefonnummer')) },
|
|
||||||
{ label: 'Send email', icon: 'envelope', action: (data) => window.location.href = `mailto:${data.email}` },
|
|
||||||
{ label: 'Opret møde', icon: 'calendar-event', action: (data) => alert('Opret møde funktionalitet kommer snart') },
|
|
||||||
{ label: 'Vis kontakt', icon: 'eye', action: (data) => window.location.href = `/contacts/${data.id}` }
|
|
||||||
],
|
|
||||||
vendor: [
|
|
||||||
{ label: 'Opret ordre', icon: 'cart-plus', action: (data) => window.location.href = `/orders/new?vendor=${data.id}` },
|
|
||||||
{ label: 'Se produkter', icon: 'box-seam', action: (data) => window.location.href = `/vendors/${data.id}/products` },
|
|
||||||
{ label: 'Vis leverandør', icon: 'eye', action: (data) => window.location.href = `/vendors/${data.id}` }
|
|
||||||
],
|
|
||||||
invoice: [
|
|
||||||
{ label: 'Vis faktura', icon: 'eye', action: (data) => window.location.href = `/invoices/${data.id}` },
|
|
||||||
{ label: 'Udskriv faktura', icon: 'printer', action: (data) => window.print() },
|
|
||||||
{ label: 'Opret kassekladde', icon: 'journal-text', action: (data) => alert('Kassekladde funktionalitet kommer snart') },
|
|
||||||
{ label: 'Opret kreditnota', icon: 'file-earmark-minus', action: (data) => window.location.href = `/invoices/${data.id}/credit-note` }
|
|
||||||
],
|
|
||||||
ticket: [
|
|
||||||
{ label: 'Åbn sag', icon: 'folder2-open', action: (data) => window.location.href = `/tickets/${data.id}` },
|
|
||||||
{ label: 'Luk sag', icon: 'check-circle', action: (data) => alert('Luk sag funktionalitet kommer snart') },
|
|
||||||
{ label: 'Tildel medarbejder', icon: 'person-plus', action: (data) => alert('Tildel funktionalitet kommer snart') }
|
|
||||||
],
|
|
||||||
rodekasse: [
|
|
||||||
{ label: 'Behandle', icon: 'pencil-square', action: (data) => window.location.href = `/rodekasse/${data.id}` },
|
|
||||||
{ label: 'Arkiver', icon: 'archive', action: (data) => alert('Arkiver funktionalitet kommer snart') },
|
|
||||||
{ label: 'Slet', icon: 'trash', action: (data) => confirm('Er du sikker?') && alert('Slet funktionalitet kommer snart') }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show contextual workflows based on entity
|
|
||||||
function showWorkflows(entityType, entityData) {
|
|
||||||
selectedEntity = entityData;
|
|
||||||
const workflowSection = document.getElementById('workflowActions');
|
|
||||||
const workflowButtons = document.getElementById('workflowButtons');
|
|
||||||
|
|
||||||
const entityWorkflows = workflows[entityType];
|
|
||||||
if (!entityWorkflows) {
|
|
||||||
workflowSection.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
workflowButtons.innerHTML = entityWorkflows.map(wf => `
|
|
||||||
<button class="btn btn-outline-primary" onclick="executeWorkflow('${entityType}', '${wf.label}')">
|
|
||||||
<i class="bi bi-${wf.icon} me-2"></i>${wf.label}
|
|
||||||
</button>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
workflowSection.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute workflow action
|
|
||||||
window.executeWorkflow = function(entityType, label) {
|
|
||||||
const workflow = workflows[entityType].find(w => w.label === label);
|
|
||||||
if (workflow && selectedEntity) {
|
|
||||||
workflow.action(selectedEntity);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Search function
|
|
||||||
searchInput.addEventListener('input', (e) => {
|
|
||||||
const query = e.target.value.trim();
|
|
||||||
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
|
|
||||||
// Reset empty state text
|
|
||||||
const emptyState = document.getElementById('emptyState');
|
|
||||||
emptyState.innerHTML = `
|
|
||||||
<i class="bi bi-search text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
|
|
||||||
<p class="text-muted mt-3">Begynd at skrive for at søge...</p>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (query.length < 2) {
|
|
||||||
emptyState.style.display = 'block';
|
|
||||||
document.getElementById('workflowActions').style.display = 'none';
|
|
||||||
document.getElementById('crmResults').style.display = 'none';
|
|
||||||
document.getElementById('supportResults').style.display = 'none';
|
|
||||||
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
|
||||||
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
|
||||||
selectedEntity = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
searchTimeout = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/dashboard/search?q=${encodeURIComponent(query)}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
emptyState.style.display = 'none';
|
|
||||||
|
|
||||||
// CRM Results (Customers + Contacts + Vendors)
|
|
||||||
const crmSection = document.getElementById('crmResults');
|
|
||||||
const allResults = [
|
|
||||||
...(data.customers || []).map(c => ({...c, entityType: 'customer', url: `/customers/${c.id}`, icon: 'building'})),
|
|
||||||
...(data.contacts || []).map(c => ({...c, entityType: 'contact', url: `/contacts/${c.id}`, icon: 'person'})),
|
|
||||||
...(data.vendors || []).map(c => ({...c, entityType: 'vendor', url: `/vendors/${c.id}`, icon: 'shop'}))
|
|
||||||
];
|
|
||||||
|
|
||||||
if (allResults.length > 0) {
|
|
||||||
crmSection.style.display = 'block';
|
|
||||||
crmSection.querySelector('.result-items').innerHTML = allResults.map(item => `
|
|
||||||
<div class="result-item p-3 mb-2 rounded" style="background: var(--bg-card); border: 1px solid transparent; transition: all 0.2s; cursor: pointer;"
|
|
||||||
onclick='showWorkflows("${item.entityType}", ${JSON.stringify(item).replace(/'/g, "'")})'>
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">
|
|
||||||
<i class="bi bi-${item.icon} text-primary"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="mb-0 fw-bold" style="color: var(--text-primary);">${item.name}</p>
|
|
||||||
<p class="mb-0 small text-muted">${item.type} ${item.email ? '• ' + item.email : ''}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-chevron-right" style="color: var(--accent);"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
// Auto-select first result
|
|
||||||
if (allResults.length > 0) {
|
|
||||||
showWorkflows(allResults[0].entityType, allResults[0]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
crmSection.style.display = 'none';
|
|
||||||
emptyState.style.display = 'block';
|
|
||||||
emptyState.innerHTML = `
|
|
||||||
<i class="bi bi-search text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
|
|
||||||
<p class="text-muted mt-3">Ingen resultater fundet for "${query}"</p>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide other sections for now as we don't have real data for them yet
|
|
||||||
document.getElementById('supportResults').style.display = 'none';
|
|
||||||
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
|
||||||
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Search error:', error);
|
|
||||||
}
|
|
||||||
}, 300); // Debounce 300ms
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hover effects for result items
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
.result-item:hover {
|
|
||||||
border-color: var(--accent) !important;
|
|
||||||
background: var(--accent-light) !important;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% block extra_js %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
174
app/vendors/backend/router.py
vendored
174
app/vendors/backend/router.py
vendored
@ -1,174 +0,0 @@
|
|||||||
"""
|
|
||||||
Vendors API Router
|
|
||||||
Endpoints for managing suppliers and vendors
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
|
||||||
from typing import List, Optional
|
|
||||||
from app.models.schemas import Vendor, VendorCreate, VendorUpdate
|
|
||||||
from app.core.database import execute_query
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/vendors", response_model=List[Vendor], tags=["Vendors"])
|
|
||||||
async def list_vendors(
|
|
||||||
search: Optional[str] = Query(None, description="Search by name, CVR, or domain"),
|
|
||||||
category: Optional[str] = Query(None, description="Filter by category"),
|
|
||||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(50, ge=1, le=100)
|
|
||||||
):
|
|
||||||
"""Get list of vendors with optional filtering"""
|
|
||||||
query = "SELECT * FROM vendors WHERE 1=1"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if search:
|
|
||||||
query += " AND (name ILIKE %s OR cvr_number ILIKE %s OR domain ILIKE %s)"
|
|
||||||
search_param = f"%{search}%"
|
|
||||||
params.extend([search_param, search_param, search_param])
|
|
||||||
|
|
||||||
if category:
|
|
||||||
query += " AND category = %s"
|
|
||||||
params.append(category)
|
|
||||||
|
|
||||||
if is_active is not None:
|
|
||||||
query += " AND is_active = %s"
|
|
||||||
params.append(is_active)
|
|
||||||
|
|
||||||
query += " ORDER BY name LIMIT %s OFFSET %s"
|
|
||||||
params.extend([limit, skip])
|
|
||||||
|
|
||||||
result = execute_query(query, tuple(params))
|
|
||||||
return result or []
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/vendors/{vendor_id}", response_model=Vendor, tags=["Vendors"])
|
|
||||||
async def get_vendor(vendor_id: int):
|
|
||||||
"""Get vendor by ID"""
|
|
||||||
query = "SELECT * FROM vendors WHERE id = %s"
|
|
||||||
result = execute_query(query, (vendor_id,))
|
|
||||||
|
|
||||||
if not result or len(result) == 0:
|
|
||||||
raise HTTPException(status_code=404, detail="Vendor not found")
|
|
||||||
|
|
||||||
return result[0]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/vendors", response_model=Vendor, tags=["Vendors"])
|
|
||||||
async def create_vendor(vendor: VendorCreate):
|
|
||||||
"""Create a new vendor"""
|
|
||||||
try:
|
|
||||||
query = """
|
|
||||||
INSERT INTO vendors (
|
|
||||||
name, cvr_number, email, phone, address, postal_code, city,
|
|
||||||
website, domain, email_pattern, category, priority, notes, is_active
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING *
|
|
||||||
"""
|
|
||||||
params = (
|
|
||||||
vendor.name, vendor.cvr_number, vendor.email, vendor.phone,
|
|
||||||
vendor.address, vendor.postal_code, vendor.city, vendor.website,
|
|
||||||
vendor.domain, vendor.email_pattern, vendor.category, vendor.priority,
|
|
||||||
vendor.notes, vendor.is_active
|
|
||||||
)
|
|
||||||
|
|
||||||
result = execute_query(query, params)
|
|
||||||
if not result or len(result) == 0:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to create vendor")
|
|
||||||
|
|
||||||
logger.info(f"✅ Created vendor: {vendor.name}")
|
|
||||||
return result[0]
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error creating vendor: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/vendors/{vendor_id}", response_model=Vendor, tags=["Vendors"])
|
|
||||||
async def update_vendor(vendor_id: int, vendor: VendorUpdate):
|
|
||||||
"""Update an existing vendor"""
|
|
||||||
# Check if vendor exists
|
|
||||||
existing = execute_query("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
|
|
||||||
if not existing:
|
|
||||||
raise HTTPException(status_code=404, detail="Vendor not found")
|
|
||||||
|
|
||||||
# Build update query
|
|
||||||
update_fields = []
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if vendor.name is not None:
|
|
||||||
update_fields.append("name = %s")
|
|
||||||
params.append(vendor.name)
|
|
||||||
if vendor.cvr_number is not None:
|
|
||||||
update_fields.append("cvr_number = %s")
|
|
||||||
params.append(vendor.cvr_number)
|
|
||||||
if vendor.email is not None:
|
|
||||||
update_fields.append("email = %s")
|
|
||||||
params.append(vendor.email)
|
|
||||||
if vendor.phone is not None:
|
|
||||||
update_fields.append("phone = %s")
|
|
||||||
params.append(vendor.phone)
|
|
||||||
if vendor.address is not None:
|
|
||||||
update_fields.append("address = %s")
|
|
||||||
params.append(vendor.address)
|
|
||||||
if vendor.postal_code is not None:
|
|
||||||
update_fields.append("postal_code = %s")
|
|
||||||
params.append(vendor.postal_code)
|
|
||||||
if vendor.city is not None:
|
|
||||||
update_fields.append("city = %s")
|
|
||||||
params.append(vendor.city)
|
|
||||||
if vendor.website is not None:
|
|
||||||
update_fields.append("website = %s")
|
|
||||||
params.append(vendor.website)
|
|
||||||
if vendor.domain is not None:
|
|
||||||
update_fields.append("domain = %s")
|
|
||||||
params.append(vendor.domain)
|
|
||||||
if vendor.email_pattern is not None:
|
|
||||||
update_fields.append("email_pattern = %s")
|
|
||||||
params.append(vendor.email_pattern)
|
|
||||||
if vendor.category is not None:
|
|
||||||
update_fields.append("category = %s")
|
|
||||||
params.append(vendor.category)
|
|
||||||
if vendor.priority is not None:
|
|
||||||
update_fields.append("priority = %s")
|
|
||||||
params.append(vendor.priority)
|
|
||||||
if vendor.notes is not None:
|
|
||||||
update_fields.append("notes = %s")
|
|
||||||
params.append(vendor.notes)
|
|
||||||
if vendor.is_active is not None:
|
|
||||||
update_fields.append("is_active = %s")
|
|
||||||
params.append(vendor.is_active)
|
|
||||||
|
|
||||||
if not update_fields:
|
|
||||||
raise HTTPException(status_code=400, detail="No fields to update")
|
|
||||||
|
|
||||||
params.append(vendor_id)
|
|
||||||
query = f"UPDATE vendors SET {', '.join(update_fields)} WHERE id = %s RETURNING *"
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = execute_query(query, tuple(params))
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to update vendor")
|
|
||||||
|
|
||||||
logger.info(f"✅ Updated vendor: {vendor_id}")
|
|
||||||
return result[0]
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error updating vendor: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/vendors/{vendor_id}", tags=["Vendors"])
|
|
||||||
async def delete_vendor(vendor_id: int):
|
|
||||||
"""Soft delete a vendor (set is_active = false)"""
|
|
||||||
query = "UPDATE vendors SET is_active = false WHERE id = %s RETURNING id"
|
|
||||||
result = execute_query(query, (vendor_id,))
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Vendor not found")
|
|
||||||
|
|
||||||
logger.info(f"✅ Deleted vendor: {vendor_id}")
|
|
||||||
return {"message": "Vendor deleted successfully"}
|
|
||||||
33
app/vendors/backend/views.py
vendored
33
app/vendors/backend/views.py
vendored
@ -1,33 +0,0 @@
|
|||||||
"""
|
|
||||||
Vendors Frontend Views
|
|
||||||
Renders vendor list and detail pages
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="app")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/vendors", response_class=HTMLResponse, tags=["Frontend"])
|
|
||||||
async def vendors_page(request: Request):
|
|
||||||
"""Render vendors list page"""
|
|
||||||
return templates.TemplateResponse("vendors/frontend/vendors.html", {
|
|
||||||
"request": request,
|
|
||||||
"title": "Leverandører"
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/vendors/{vendor_id}", response_class=HTMLResponse, tags=["Frontend"])
|
|
||||||
async def vendor_detail_page(request: Request, vendor_id: int):
|
|
||||||
"""Render vendor detail page"""
|
|
||||||
return templates.TemplateResponse("vendors/frontend/vendor_detail.html", {
|
|
||||||
"request": request,
|
|
||||||
"vendor_id": vendor_id,
|
|
||||||
"title": "Leverandør Detaljer"
|
|
||||||
})
|
|
||||||
686
app/vendors/frontend/vendor_detail.html
vendored
686
app/vendors/frontend/vendor_detail.html
vendored
@ -1,686 +0,0 @@
|
|||||||
{% extends "shared/frontend/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Leverandør Detaljer - BMC Hub{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.vendor-header {
|
|
||||||
background: linear-gradient(135deg, var(--accent) 0%, #1e6ba8 100%);
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
color: white;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vendor-avatar-large {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 2rem;
|
|
||||||
border: 3px solid rgba(255,255,255,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-nav {
|
|
||||||
position: sticky;
|
|
||||||
top: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-nav .nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-nav .nav-link:hover,
|
|
||||||
.vertical-nav .nav-link.active {
|
|
||||||
background: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
border-left-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-badge-large {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.priority-indicator {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- Vendor Header -->
|
|
||||||
<div class="vendor-header" id="vendorHeader">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-auto">
|
|
||||||
<div class="vendor-avatar-large" id="vendorAvatar"></div>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="d-flex align-items-center gap-3 mb-2">
|
|
||||||
<h2 class="mb-0 fw-bold" id="vendorName">Loading...</h2>
|
|
||||||
<span class="badge bg-white text-dark" id="vendorStatus"></span>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-4 text-white-50">
|
|
||||||
<span id="vendorDomain"></span>
|
|
||||||
<span id="vendorCategory"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<button class="btn btn-light" onclick="editVendor()">
|
|
||||||
<i class="bi bi-pencil me-2"></i>Rediger
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row">
|
|
||||||
<!-- Vertical Navigation -->
|
|
||||||
<div class="col-lg-2">
|
|
||||||
<div class="vertical-nav">
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<a class="nav-link active" href="#oversigt" data-tab="oversigt">
|
|
||||||
<i class="bi bi-info-circle me-2"></i>Oversigt
|
|
||||||
</a>
|
|
||||||
<a class="nav-link" href="#produkter" data-tab="produkter">
|
|
||||||
<i class="bi bi-box-seam me-2"></i>Produkter
|
|
||||||
</a>
|
|
||||||
<a class="nav-link" href="#fakturaer" data-tab="fakturaer">
|
|
||||||
<i class="bi bi-receipt me-2"></i>Fakturaer
|
|
||||||
</a>
|
|
||||||
<a class="nav-link" href="#aktivitet" data-tab="aktivitet">
|
|
||||||
<i class="bi bi-clock-history me-2"></i>Aktivitet
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Area -->
|
|
||||||
<div class="col-lg-10">
|
|
||||||
<div class="tab-content">
|
|
||||||
<!-- Oversigt Tab -->
|
|
||||||
<div class="tab-pane fade show active" id="oversigt">
|
|
||||||
<div class="row g-4">
|
|
||||||
<!-- Vendor Information -->
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card p-4">
|
|
||||||
<h5 class="mb-4 fw-bold">Leverandør Information</h5>
|
|
||||||
<div id="vendorInfo">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contact & System Info -->
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card p-4 mb-4">
|
|
||||||
<h5 class="mb-4 fw-bold">Kontakt Information</h5>
|
|
||||||
<div id="contactInfo">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card p-4">
|
|
||||||
<h5 class="mb-4 fw-bold">System Information</h5>
|
|
||||||
<div id="systemInfo">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Produkter Tab -->
|
|
||||||
<div class="tab-pane fade" id="produkter">
|
|
||||||
<div class="card p-4">
|
|
||||||
<h5 class="mb-4 fw-bold">Produkter fra denne leverandør</h5>
|
|
||||||
<p class="text-muted">Produkt tracking kommer snart...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fakturaer Tab -->
|
|
||||||
<div class="tab-pane fade" id="fakturaer">
|
|
||||||
<div class="card p-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h5 class="mb-0 fw-bold">Leverandør Fakturaer</h5>
|
|
||||||
<span class="badge bg-primary" id="invoiceCount">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Fakturanr.</th>
|
|
||||||
<th>Dato</th>
|
|
||||||
<th>Forfald</th>
|
|
||||||
<th>Beløb</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th class="text-end">Handling</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="invoicesTableBody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="text-center py-4">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Aktivitet Tab -->
|
|
||||||
<div class="tab-pane fade" id="aktivitet">
|
|
||||||
<div class="card p-4">
|
|
||||||
<h5 class="mb-4 fw-bold">Aktivitetslog</h5>
|
|
||||||
<p class="text-muted">Aktivitetshistorik kommer snart...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Edit Vendor Modal -->
|
|
||||||
<div class="modal fade" id="editVendorModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="bi bi-pencil me-2"></i>Rediger Leverandør</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="editVendorForm">
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<label class="form-label">Navn *</label>
|
|
||||||
<input type="text" class="form-control" id="editName" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">CVR-nummer</label>
|
|
||||||
<input type="text" class="form-control" id="editCvr" maxlength="8" pattern="[0-9]{8}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Kategori</label>
|
|
||||||
<select class="form-select" id="editCategory">
|
|
||||||
<option value="IT & Software">IT & Software</option>
|
|
||||||
<option value="Telefoni & Internet">Telefoni & Internet</option>
|
|
||||||
<option value="Hardware">Hardware</option>
|
|
||||||
<option value="Cloud Services">Cloud Services</option>
|
|
||||||
<option value="Hosting">Hosting</option>
|
|
||||||
<option value="Security">Security</option>
|
|
||||||
<option value="Consulting">Consulting</option>
|
|
||||||
<option value="Andet">Andet</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Domæne</label>
|
|
||||||
<input type="text" class="form-control" id="editDomain" placeholder="example.com">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Email</label>
|
|
||||||
<input type="email" class="form-control" id="editEmail">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Telefon</label>
|
|
||||||
<input type="tel" class="form-control" id="editPhone">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<label class="form-label">Adresse</label>
|
|
||||||
<input type="text" class="form-control" id="editAddress">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Postnr. & By</label>
|
|
||||||
<input type="text" class="form-control" id="editCity">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">e-conomic Leverandør Nr.</label>
|
|
||||||
<input type="text" class="form-control" id="editEconomicNumber">
|
|
||||||
<div class="form-text">Til integration med e-conomic</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Noter</label>
|
|
||||||
<textarea class="form-control" id="editNotes" rows="3"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="editIsActive">
|
|
||||||
<label class="form-check-label" for="editIsActive">Aktiv leverandør</label>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</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="saveVendor()">
|
|
||||||
<i class="bi bi-save me-2"></i>Gem Ændringer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
const vendorId = {{ vendor_id }};
|
|
||||||
|
|
||||||
async function loadVendor() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/vendors/${vendorId}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Vendor not found');
|
|
||||||
}
|
|
||||||
const vendor = await response.json();
|
|
||||||
displayVendor(vendor);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading vendor:', error);
|
|
||||||
document.getElementById('vendorName').textContent = 'Fejl ved indlæsning';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayVendor(vendor) {
|
|
||||||
// Header
|
|
||||||
document.getElementById('vendorName').textContent = vendor.name;
|
|
||||||
document.getElementById('vendorAvatar').textContent = getInitials(vendor.name);
|
|
||||||
document.getElementById('vendorStatus').textContent = vendor.is_active ? 'Aktiv' : 'Inaktiv';
|
|
||||||
document.getElementById('vendorStatus').className = `badge ${vendor.is_active ? 'bg-success' : 'bg-secondary'}`;
|
|
||||||
document.getElementById('vendorDomain').innerHTML = vendor.domain ? `<i class="bi bi-globe me-2"></i>${vendor.domain}` : '';
|
|
||||||
document.getElementById('vendorCategory').innerHTML = `${getCategoryIcon(vendor.category)} ${vendor.category}`;
|
|
||||||
|
|
||||||
// Update page title
|
|
||||||
document.title = `${vendor.name} - BMC Hub`;
|
|
||||||
|
|
||||||
// Vendor Info
|
|
||||||
document.getElementById('vendorInfo').innerHTML = `
|
|
||||||
${vendor.cvr_number ? `
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-label">CVR-nummer</div>
|
|
||||||
<div class="info-value fw-semibold">${escapeHtml(vendor.cvr_number)}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-label">Kategori</div>
|
|
||||||
<div class="info-value">
|
|
||||||
<span class="category-badge-large bg-light">
|
|
||||||
${getCategoryIcon(vendor.category)} ${escapeHtml(vendor.category)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${vendor.economic_supplier_number ? `
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-label">e-conomic Leverandør Nr.</div>
|
|
||||||
<div class="info-value">${vendor.economic_supplier_number}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${vendor.notes ? `
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-label">Noter</div>
|
|
||||||
<div class="info-value">${escapeHtml(vendor.notes)}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Contact Info
|
|
||||||
document.getElementById('contactInfo').innerHTML = `
|
|
||||||
${vendor.email ? `
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-label">Email</div>
|
|
||||||
<div class="info-value">
|
|
||||||
<a href="mailto:${escapeHtml(vendor.email)}" class="text-decoration-none">
|
|
||||||
<i class="bi bi-envelope me-2"></i>${escapeHtml(vendor.email)}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${vendor.phone ? `
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-label">Telefon</div>
|
|
||||||
<div class="info-value">
|
|
||||||
<a href="tel:${escapeHtml(vendor.phone)}" class="text-decoration-none">
|
|
||||||
<i class="bi bi-telephone me-2"></i>${escapeHtml(vendor.phone)}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${vendor.website ? `
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-label">Website</div>
|
|
||||||
<div class="info-value">
|
|
||||||
<a href="${escapeHtml(vendor.website)}" target="_blank" class="text-decoration-none">
|
|
||||||
<i class="bi bi-globe me-2"></i>${escapeHtml(vendor.website)}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${vendor.address ? `
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-label">Adresse</div>
|
|
||||||
<div class="info-value">
|
|
||||||
<i class="bi bi-geo-alt me-2"></i>
|
|
||||||
${escapeHtml(vendor.address)}
|
|
||||||
${vendor.postal_code || vendor.city ? `<br>${vendor.postal_code ? escapeHtml(vendor.postal_code) + ' ' : ''}${vendor.city ? escapeHtml(vendor.city) : ''}` : ''}
|
|
||||||
${vendor.country && vendor.country !== 'Danmark' ? `<br>${escapeHtml(vendor.country)}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${vendor.email_pattern ? `
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-label">Email Pattern</div>
|
|
||||||
<div class="info-value"><code>${escapeHtml(vendor.email_pattern)}</code></div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// System Info
|
|
||||||
document.getElementById('systemInfo').innerHTML = `
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-label">Oprettet</div>
|
|
||||||
<div class="info-value">${formatDate(vendor.created_at)}</div>
|
|
||||||
</div>
|
|
||||||
${vendor.updated_at ? `
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-label">Sidst opdateret</div>
|
|
||||||
<div class="info-value">${formatDate(vendor.updated_at)}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-label">ID</div>
|
|
||||||
<div class="info-value"><code>#${vendor.id}</code></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Load invoices
|
|
||||||
loadVendorInvoices();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadVendorInvoices() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/supplier-invoices?vendor_id=${vendorId}`);
|
|
||||||
if (!response.ok) throw new Error('Failed to load invoices');
|
|
||||||
|
|
||||||
const invoices = await response.json();
|
|
||||||
displayInvoices(invoices);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading invoices:', error);
|
|
||||||
document.getElementById('invoicesTableBody').innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="text-center text-muted py-4">
|
|
||||||
Kunne ikke indlæse fakturaer
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayInvoices(invoices) {
|
|
||||||
const tbody = document.getElementById('invoicesTableBody');
|
|
||||||
const count = document.getElementById('invoiceCount');
|
|
||||||
|
|
||||||
count.textContent = invoices.length;
|
|
||||||
|
|
||||||
if (invoices.length === 0) {
|
|
||||||
tbody.innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="text-center text-muted py-4">
|
|
||||||
Ingen fakturaer fundet for denne leverandør
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = invoices.map(invoice => {
|
|
||||||
const statusClass = getInvoiceStatusClass(invoice.status);
|
|
||||||
const statusText = getInvoiceStatusText(invoice.status);
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr>
|
|
||||||
<td><strong>${escapeHtml(invoice.invoice_number)}</strong></td>
|
|
||||||
<td>${formatDateShort(invoice.invoice_date)}</td>
|
|
||||||
<td>${formatDateShort(invoice.due_date)}</td>
|
|
||||||
<td><strong>${formatCurrency(invoice.total_amount, invoice.currency)}</strong></td>
|
|
||||||
<td><span class="badge ${statusClass}">${statusText}</span></td>
|
|
||||||
<td class="text-end">
|
|
||||||
<a href="/billing/supplier-invoices?invoice=${invoice.id}" class="btn btn-sm btn-outline-primary">
|
|
||||||
<i class="bi bi-eye"></i>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInvoiceStatusClass(status) {
|
|
||||||
const classes = {
|
|
||||||
'unpaid': 'bg-warning text-dark',
|
|
||||||
'paid': 'bg-success',
|
|
||||||
'overdue': 'bg-danger',
|
|
||||||
'cancelled': 'bg-secondary',
|
|
||||||
'pending': 'bg-info'
|
|
||||||
};
|
|
||||||
return classes[status] || 'bg-secondary';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInvoiceStatusText(status) {
|
|
||||||
const texts = {
|
|
||||||
'unpaid': 'Ubetalt',
|
|
||||||
'paid': 'Betalt',
|
|
||||||
'overdue': 'Forfalden',
|
|
||||||
'cancelled': 'Annulleret',
|
|
||||||
'pending': 'Afventer'
|
|
||||||
};
|
|
||||||
return texts[status] || status;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateShort(dateString) {
|
|
||||||
if (!dateString) return '-';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCurrency(amount, currency = 'DKK') {
|
|
||||||
if (!amount) return '-';
|
|
||||||
return new Intl.NumberFormat('da-DK', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency
|
|
||||||
}).format(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCategoryIcon(category) {
|
|
||||||
const icons = {
|
|
||||||
hardware: '🖥️',
|
|
||||||
software: '💻',
|
|
||||||
telecom: '📡',
|
|
||||||
services: '🛠️',
|
|
||||||
hosting: '☁️',
|
|
||||||
general: '📦'
|
|
||||||
};
|
|
||||||
return icons[category] || '📦';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPriorityClass(priority) {
|
|
||||||
if (priority >= 80) return 'bg-danger text-white';
|
|
||||||
if (priority >= 60) return 'bg-warning';
|
|
||||||
if (priority >= 40) return 'bg-info';
|
|
||||||
return 'bg-secondary text-white';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitials(name) {
|
|
||||||
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function editVendor() {
|
|
||||||
// Get current vendor data and populate form
|
|
||||||
fetch(`/api/v1/vendors/${vendorId}`)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(vendor => {
|
|
||||||
document.getElementById('editName').value = vendor.name || '';
|
|
||||||
document.getElementById('editCvr').value = vendor.cvr_number || '';
|
|
||||||
document.getElementById('editCategory').value = vendor.category || 'Andet';
|
|
||||||
document.getElementById('editDomain').value = vendor.domain || '';
|
|
||||||
document.getElementById('editEmail').value = vendor.email || '';
|
|
||||||
document.getElementById('editPhone').value = vendor.phone || '';
|
|
||||||
document.getElementById('editAddress').value = vendor.address || '';
|
|
||||||
document.getElementById('editCity').value = vendor.city || '';
|
|
||||||
document.getElementById('editEconomicNumber').value = vendor.economic_supplier_number || '';
|
|
||||||
document.getElementById('editNotes').value = vendor.notes || '';
|
|
||||||
document.getElementById('editIsActive').checked = vendor.is_active;
|
|
||||||
|
|
||||||
new bootstrap.Modal(document.getElementById('editVendorModal')).show();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error loading vendor for edit:', error);
|
|
||||||
alert('Kunne ikke hente leverandør data');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveVendor() {
|
|
||||||
try {
|
|
||||||
const data = {
|
|
||||||
name: document.getElementById('editName').value.trim(),
|
|
||||||
cvr_number: document.getElementById('editCvr').value.trim() || null,
|
|
||||||
category: document.getElementById('editCategory').value,
|
|
||||||
domain: document.getElementById('editDomain').value.trim() || null,
|
|
||||||
email: document.getElementById('editEmail').value.trim() || null,
|
|
||||||
phone: document.getElementById('editPhone').value.trim() || null,
|
|
||||||
address: document.getElementById('editAddress').value.trim() || null,
|
|
||||||
city: document.getElementById('editCity').value.trim() || null,
|
|
||||||
economic_supplier_number: document.getElementById('editEconomicNumber').value.trim() || null,
|
|
||||||
notes: document.getElementById('editNotes').value.trim() || null,
|
|
||||||
is_active: document.getElementById('editIsActive').checked
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data.name) {
|
|
||||||
alert('Navn er påkrævet');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/vendors/${vendorId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.detail || 'Kunne ikke gemme ændringer');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal and reload vendor
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('editVendorModal')).hide();
|
|
||||||
await loadVendor();
|
|
||||||
alert('✅ Leverandør opdateret!');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving vendor:', error);
|
|
||||||
alert('Fejl: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString) {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('da-DK', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab navigation
|
|
||||||
document.querySelectorAll('.vertical-nav .nav-link').forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const tab = link.dataset.tab;
|
|
||||||
|
|
||||||
// Update nav
|
|
||||||
document.querySelectorAll('.vertical-nav .nav-link').forEach(l => l.classList.remove('active'));
|
|
||||||
link.classList.add('active');
|
|
||||||
|
|
||||||
// Update content
|
|
||||||
document.querySelectorAll('.tab-pane').forEach(pane => {
|
|
||||||
pane.classList.remove('show', 'active');
|
|
||||||
});
|
|
||||||
document.getElementById(tab).classList.add('show', 'active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load vendor on page ready
|
|
||||||
document.addEventListener('DOMContentLoaded', loadVendor);
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
414
app/vendors/frontend/vendors.html
vendored
414
app/vendors/frontend/vendors.html
vendored
@ -1,414 +0,0 @@
|
|||||||
{% extends "shared/frontend/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Leverandører - BMC Hub{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.filter-btn {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid rgba(0,0,0,0.1);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.5rem 1.2rem;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn:hover, .filter-btn.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vendor-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-badge {
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.priority-badge {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Leverandører</h2>
|
|
||||||
<p class="text-muted mb-0">Administrer dine leverandører og partnere</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" id="searchInput" class="header-search" placeholder="Søg leverandør, CVR, domain...">
|
|
||||||
<button class="btn btn-primary" onclick="showCreateVendorModal()">
|
|
||||||
<i class="bi bi-plus-lg me-2"></i>Opret Leverandør
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4 d-flex gap-2 flex-wrap">
|
|
||||||
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
|
|
||||||
Alle <span id="countAll" class="ms-1"></span>
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn" data-filter="hardware" onclick="setFilter('hardware')">
|
|
||||||
Hardware
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn" data-filter="software" onclick="setFilter('software')">
|
|
||||||
Software
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn" data-filter="telecom" onclick="setFilter('telecom')">
|
|
||||||
Telekom
|
|
||||||
</button>
|
|
||||||
<button class="filter-btn" data-filter="services" onclick="setFilter('services')">
|
|
||||||
Services
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card p-4">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Leverandør</th>
|
|
||||||
<th>Kontakt Info</th>
|
|
||||||
<th>CVR</th>
|
|
||||||
<th>Kategori</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th class="text-end">Handlinger</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="vendorsTableBody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
|
||||||
<div class="text-muted small">
|
|
||||||
Viser <span id="showingStart">0</span>-<span id="showingEnd">0</span> af <span id="totalCount">0</span> leverandører
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" id="prevBtn" onclick="previousPage()">
|
|
||||||
<i class="bi bi-chevron-left"></i> Forrige
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" id="nextBtn" onclick="nextPage()">
|
|
||||||
Næste <i class="bi bi-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create Vendor Modal -->
|
|
||||||
<div class="modal fade" id="createVendorModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Opret Ny Leverandør</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="createVendorForm">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<label class="form-label">Virksomhedsnavn *</label>
|
|
||||||
<input type="text" class="form-control" id="name" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">CVR-nummer</label>
|
|
||||||
<input type="text" class="form-control" id="cvr_number" maxlength="8">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Email</label>
|
|
||||||
<input type="email" class="form-control" id="email">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Telefon</label>
|
|
||||||
<input type="text" class="form-control" id="phone">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Website</label>
|
|
||||||
<input type="url" class="form-control" id="website">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Domain</label>
|
|
||||||
<input type="text" class="form-control" id="domain" placeholder="example.com">
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">Adresse</label>
|
|
||||||
<input type="text" class="form-control" id="address">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Postnummer</label>
|
|
||||||
<input type="text" class="form-control" id="postal_code">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-5">
|
|
||||||
<label class="form-label">By</label>
|
|
||||||
<input type="text" class="form-control" id="city">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Kategori</label>
|
|
||||||
<select class="form-select" id="category">
|
|
||||||
<option value="general">General</option>
|
|
||||||
<option value="hardware">Hardware</option>
|
|
||||||
<option value="software">Software</option>
|
|
||||||
<option value="telecom">Telekom</option>
|
|
||||||
<option value="services">Services</option>
|
|
||||||
<option value="hosting">Hosting</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">Noter</label>
|
|
||||||
<textarea class="form-control" id="notes" rows="3"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</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="createVendor()">
|
|
||||||
<i class="bi bi-check-lg me-2"></i>Opret Leverandør
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
let currentPage = 0;
|
|
||||||
let pageSize = 50;
|
|
||||||
let currentFilter = 'all';
|
|
||||||
let searchTerm = '';
|
|
||||||
|
|
||||||
async function loadVendors() {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
skip: currentPage * pageSize,
|
|
||||||
limit: pageSize
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchTerm) {
|
|
||||||
params.append('search', searchTerm);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentFilter !== 'all') {
|
|
||||||
params.append('category', currentFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔄 Loading vendors from:', `/api/v1/vendors?${params}`);
|
|
||||||
const response = await fetch(`/api/v1/vendors?${params}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const vendors = await response.json();
|
|
||||||
console.log('✅ Loaded vendors:', vendors.length);
|
|
||||||
|
|
||||||
displayVendors(vendors);
|
|
||||||
updatePagination(vendors.length);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error loading vendors:', error);
|
|
||||||
document.getElementById('vendorsTableBody').innerHTML = `
|
|
||||||
<tr><td colspan="7" class="text-center text-danger py-5">
|
|
||||||
<i class="bi bi-exclamation-triangle fs-2 d-block mb-2"></i>
|
|
||||||
<strong>Kunne ikke indlæse leverandører</strong><br>
|
|
||||||
<small class="text-muted">${error.message}</small>
|
|
||||||
</td></tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayVendors(vendors) {
|
|
||||||
const tbody = document.getElementById('vendorsTableBody');
|
|
||||||
|
|
||||||
if (vendors.length === 0) {
|
|
||||||
tbody.innerHTML = `
|
|
||||||
<tr><td colspan="7" class="text-center text-muted py-5">
|
|
||||||
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
|
|
||||||
Ingen leverandører fundet
|
|
||||||
</td></tr>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = vendors.map(vendor => `
|
|
||||||
<tr onclick="window.location.href='/vendors/${vendor.id}'" style="cursor: pointer;">
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<div class="vendor-avatar">${getInitials(vendor.name)}</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-semibold">${escapeHtml(vendor.name)}</div>
|
|
||||||
${vendor.domain ? `<small class="text-muted">${escapeHtml(vendor.domain)}</small>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
${vendor.email ? `<div><i class="bi bi-envelope me-2"></i>${escapeHtml(vendor.email)}</div>` : ''}
|
|
||||||
${vendor.phone ? `<div><i class="bi bi-telephone me-2"></i>${escapeHtml(vendor.phone)}</div>` : ''}
|
|
||||||
${!vendor.email && !vendor.phone ? '<span class="text-muted">-</span>' : ''}
|
|
||||||
</td>
|
|
||||||
<td>${vendor.cvr_number ? escapeHtml(vendor.cvr_number) : '<span class="text-muted">-</span>'}</td>
|
|
||||||
<td>
|
|
||||||
<span class="category-badge bg-light">
|
|
||||||
${getCategoryIcon(vendor.category)} ${escapeHtml(vendor.category)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge ${vendor.is_active ? 'bg-success' : 'bg-secondary'}">
|
|
||||||
${vendor.is_active ? 'Aktiv' : 'Inaktiv'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); editVendor(${vendor.id})">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCategoryIcon(category) {
|
|
||||||
const icons = {
|
|
||||||
hardware: '🖥️',
|
|
||||||
software: '💻',
|
|
||||||
telecom: '📡',
|
|
||||||
services: '🛠️',
|
|
||||||
hosting: '☁️',
|
|
||||||
general: '📦'
|
|
||||||
};
|
|
||||||
return icons[category] || '📦';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitials(name) {
|
|
||||||
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setFilter(filter) {
|
|
||||||
currentFilter = filter;
|
|
||||||
currentPage = 0;
|
|
||||||
|
|
||||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
|
||||||
btn.classList.toggle('active', btn.dataset.filter === filter);
|
|
||||||
});
|
|
||||||
|
|
||||||
loadVendors();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePagination(count) {
|
|
||||||
document.getElementById('showingStart').textContent = currentPage * pageSize + 1;
|
|
||||||
document.getElementById('showingEnd').textContent = currentPage * pageSize + count;
|
|
||||||
document.getElementById('totalCount').textContent = count;
|
|
||||||
|
|
||||||
document.getElementById('prevBtn').disabled = currentPage === 0;
|
|
||||||
document.getElementById('nextBtn').disabled = count < pageSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
function previousPage() {
|
|
||||||
if (currentPage > 0) {
|
|
||||||
currentPage--;
|
|
||||||
loadVendors();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextPage() {
|
|
||||||
currentPage++;
|
|
||||||
loadVendors();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCreateVendorModal() {
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('createVendorModal'));
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createVendor() {
|
|
||||||
const form = document.getElementById('createVendorForm');
|
|
||||||
|
|
||||||
const vendor = {
|
|
||||||
name: document.getElementById('name').value,
|
|
||||||
cvr_number: document.getElementById('cvr_number').value || null,
|
|
||||||
email: document.getElementById('email').value || null,
|
|
||||||
phone: document.getElementById('phone').value || null,
|
|
||||||
website: document.getElementById('website').value || null,
|
|
||||||
domain: document.getElementById('domain').value || null,
|
|
||||||
address: document.getElementById('address').value || null,
|
|
||||||
postal_code: document.getElementById('postal_code').value || null,
|
|
||||||
city: document.getElementById('city').value || null,
|
|
||||||
category: document.getElementById('category').value,
|
|
||||||
priority: parseInt(document.getElementById('priority').value),
|
|
||||||
notes: document.getElementById('notes').value || null,
|
|
||||||
is_active: true
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/vendors', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(vendor)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('createVendorModal')).hide();
|
|
||||||
form.reset();
|
|
||||||
loadVendors();
|
|
||||||
} else {
|
|
||||||
alert('Fejl ved oprettelse af leverandør');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating vendor:', error);
|
|
||||||
alert('Kunne ikke oprette leverandør');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search
|
|
||||||
let vendorSearchTimeout;
|
|
||||||
document.getElementById('searchInput').addEventListener('input', (e) => {
|
|
||||||
clearTimeout(vendorSearchTimeout);
|
|
||||||
vendorSearchTimeout = setTimeout(() => {
|
|
||||||
searchTerm = e.target.value;
|
|
||||||
currentPage = 0;
|
|
||||||
loadVendors();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load on page ready
|
|
||||||
document.addEventListener('DOMContentLoaded', loadVendors);
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -42,17 +42,12 @@ services:
|
|||||||
# Mount for local development - live code reload
|
# Mount for local development - live code reload
|
||||||
- ./app:/app/app:ro
|
- ./app:/app/app:ro
|
||||||
- ./main.py:/app/main.py:ro
|
- ./main.py:/app/main.py:ro
|
||||||
- ./scripts:/app/scripts:ro
|
|
||||||
# Mount OmniSync database for import (read-only)
|
|
||||||
- /Users/christianthomas/pakkemodtagelse/data:/omnisync_data:ro
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
# Override database URL to point to postgres service
|
# Override database URL to point to postgres service
|
||||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
||||||
- ENABLE_RELOAD=false
|
- ENABLE_RELOAD=false
|
||||||
- OLLAMA_MODEL=qwen3:4b # Bruger Chat API format
|
|
||||||
- OLLAMA_MODEL_FALLBACK=qwen2.5:3b # Backup model
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
# PostgreSQL Database
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: bmc-hub-postgres
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-bmc_hub}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-bmc_hub}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-bmc_hub}
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
- ./migrations/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
|
||||||
ports:
|
|
||||||
- "${POSTGRES_PORT:-5433}:5432"
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-bmc_hub}"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- bmc-hub-network
|
|
||||||
|
|
||||||
# FastAPI Application
|
|
||||||
api:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: bmc-hub-api
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
ports:
|
|
||||||
- "${API_PORT:-8001}:8000"
|
|
||||||
volumes:
|
|
||||||
- ./logs:/app/logs
|
|
||||||
- ./uploads:/app/uploads
|
|
||||||
- ./static:/app/static
|
|
||||||
- ./data:/app/data
|
|
||||||
# Mount for local development - live code reload
|
|
||||||
- ./app:/app/app:ro
|
|
||||||
- ./main.py:/app/main.py:ro
|
|
||||||
- ./scripts:/app/scripts:ro
|
|
||||||
# Mount OmniSync database for import (read-only)
|
|
||||||
- /Users/christianthomas/pakkemodtagelse/data:/omnisync_data:ro
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
# Override database URL to point to postgres service
|
|
||||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
|
||||||
- ENABLE_RELOAD=false
|
|
||||||
- OLLAMA_MODEL=qwen3:4b
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
networks:
|
|
||||||
- bmc-hub-network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
bmc-hub-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
driver: local
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
# PostgreSQL Database
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: bmc-hub-postgres
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-bmc_hub}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-bmc_hub}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-bmc_hub}
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
- ./migrations/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
|
||||||
ports:
|
|
||||||
- "${POSTGRES_PORT:-5433}:5432"
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-bmc_hub}"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- bmc-hub-network
|
|
||||||
|
|
||||||
# FastAPI Application
|
|
||||||
api:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: bmc-hub-api
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
ports:
|
|
||||||
- "${API_PORT:-8001}:8000"
|
|
||||||
volumes:
|
|
||||||
- ./logs:/app/logs
|
|
||||||
- ./uploads:/app/uploads
|
|
||||||
- ./static:/app/static
|
|
||||||
- ./data:/app/data
|
|
||||||
# Mount for local development - live code reload
|
|
||||||
- ./app:/app/app:ro
|
|
||||||
- ./main.py:/app/main.py:ro
|
|
||||||
- ./scripts:/app/scripts:ro
|
|
||||||
# Mount OmniSync database for import (read-only)
|
|
||||||
- /Users/christianthomas/pakkemodtagelse/data:/omnisync_data:ro
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
# Override database URL to point to postgres service
|
|
||||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
|
||||||
- ENABLE_RELOAD=false
|
|
||||||
- OLLAMA_MODEL=qwen2.5:3b
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
networks:
|
|
||||||
- bmc-hub-network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
bmc-hub-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
driver: local
|
|
||||||
@ -1,229 +0,0 @@
|
|||||||
# DEV Portal - Dokumentation
|
|
||||||
|
|
||||||
## Oversigt
|
|
||||||
|
|
||||||
DEV Portal er en selvstændig app i BMC Hub til at planlægge og dokumentere udviklingsarbejde.
|
|
||||||
|
|
||||||
**URL**: [http://localhost:8001/devportal](http://localhost:8001/devportal)
|
|
||||||
|
|
||||||
**Adgang**: Via bruger dropdown menu → "DEV Portal"
|
|
||||||
|
|
||||||
## Funktioner
|
|
||||||
|
|
||||||
### 1. Roadmap (Kanban Board)
|
|
||||||
- **Version Tagging**: V1, V2, V3, osv.
|
|
||||||
- **Statuser**:
|
|
||||||
- ✅ Planlagt
|
|
||||||
- ⏳ I Gang
|
|
||||||
- ✅ Færdig
|
|
||||||
- ⏸️ Sat på Pause
|
|
||||||
- **Prioritering**: 1-100 (lav-høj)
|
|
||||||
- **Datoer**: Forventet dato + afsluttet dato
|
|
||||||
- **Filtering**: Filtrer efter version (V1/V2/V3)
|
|
||||||
|
|
||||||
### 2. Idéer (Brainstorming)
|
|
||||||
- Opret nye idéer med titel, beskrivelse og kategori
|
|
||||||
- **Voting System**: Stem på gode idéer (thumbs up)
|
|
||||||
- **Kategorier**: UI/UX, Integration, Automatisering, Sikkerhed, Andet
|
|
||||||
- Sortering: Automatisk efter antal stemmer
|
|
||||||
|
|
||||||
### 3. Workflows (Diagram Editor)
|
|
||||||
- **Draw.io Integration**: Embedded diagram editor
|
|
||||||
- **Typer**: Flowchart, Proces, System Diagram
|
|
||||||
- **Lagring**: XML gemmes direkte i database
|
|
||||||
- **Redigering**: Klik "Rediger" for at åbne eksisterende workflow
|
|
||||||
|
|
||||||
## Database Struktur
|
|
||||||
|
|
||||||
### dev_features
|
|
||||||
```sql
|
|
||||||
- id (SERIAL PRIMARY KEY)
|
|
||||||
- title (VARCHAR(255))
|
|
||||||
- description (TEXT)
|
|
||||||
- version (VARCHAR(50)) -- V1, V2, V3, etc.
|
|
||||||
- status (VARCHAR(50)) -- planlagt, i gang, færdig, sat på pause
|
|
||||||
- priority (INTEGER) -- 1-100
|
|
||||||
- expected_date (DATE)
|
|
||||||
- completed_date (DATE)
|
|
||||||
- created_at, updated_at (TIMESTAMP)
|
|
||||||
```
|
|
||||||
|
|
||||||
### dev_ideas
|
|
||||||
```sql
|
|
||||||
- id (SERIAL PRIMARY KEY)
|
|
||||||
- title (VARCHAR(255))
|
|
||||||
- description (TEXT)
|
|
||||||
- category (VARCHAR(50))
|
|
||||||
- votes (INTEGER) -- voting system
|
|
||||||
- created_at, updated_at (TIMESTAMP)
|
|
||||||
```
|
|
||||||
|
|
||||||
### dev_workflows
|
|
||||||
```sql
|
|
||||||
- id (SERIAL PRIMARY KEY)
|
|
||||||
- title (VARCHAR(255))
|
|
||||||
- description (TEXT)
|
|
||||||
- category (VARCHAR(50))
|
|
||||||
- diagram_xml (TEXT) -- draw.io XML format
|
|
||||||
- thumbnail_url (VARCHAR(500))
|
|
||||||
- created_at, updated_at (TIMESTAMP)
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- `GET /api/v1/devportal/features` - List alle features
|
|
||||||
- Query params: `?version=V1&status=færdig`
|
|
||||||
- `GET /api/v1/devportal/features/{id}` - Hent specifik feature
|
|
||||||
- `POST /api/v1/devportal/features` - Opret ny feature
|
|
||||||
- `PUT /api/v1/devportal/features/{id}` - Opdater feature
|
|
||||||
- `DELETE /api/v1/devportal/features/{id}` - Slet feature
|
|
||||||
|
|
||||||
### Ideas
|
|
||||||
- `GET /api/v1/devportal/ideas` - List alle idéer
|
|
||||||
- Query params: `?category=integration`
|
|
||||||
- `POST /api/v1/devportal/ideas` - Opret ny idé
|
|
||||||
- `POST /api/v1/devportal/ideas/{id}/vote` - Stem på idé
|
|
||||||
- `DELETE /api/v1/devportal/ideas/{id}` - Slet idé
|
|
||||||
|
|
||||||
### Workflows
|
|
||||||
- `GET /api/v1/devportal/workflows` - List alle workflows
|
|
||||||
- Query params: `?category=process`
|
|
||||||
- `GET /api/v1/devportal/workflows/{id}` - Hent specifik workflow
|
|
||||||
- `POST /api/v1/devportal/workflows` - Opret ny workflow
|
|
||||||
- `PUT /api/v1/devportal/workflows/{id}` - Opdater workflow
|
|
||||||
- `DELETE /api/v1/devportal/workflows/{id}` - Slet workflow
|
|
||||||
|
|
||||||
### Stats
|
|
||||||
- `GET /api/v1/devportal/stats` - Hent statistik
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"features_count": 6,
|
|
||||||
"ideas_count": 4,
|
|
||||||
"workflows_count": 0,
|
|
||||||
"features_by_status": [
|
|
||||||
{"status": "færdig", "count": 3},
|
|
||||||
{"status": "planlagt", "count": 3}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend Routes
|
|
||||||
|
|
||||||
- `GET /devportal` - Hovedside med Kanban board, idéer og workflows
|
|
||||||
- `GET /devportal/editor?id={id}` - Workflow editor (draw.io)
|
|
||||||
|
|
||||||
## Draw.io Integration
|
|
||||||
|
|
||||||
### Embed URL
|
|
||||||
```
|
|
||||||
https://embed.diagrams.net/?embed=1&ui=kennedy&spin=1&proto=json
|
|
||||||
```
|
|
||||||
|
|
||||||
### postMessage API
|
|
||||||
```javascript
|
|
||||||
// Load existing diagram
|
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({
|
|
||||||
action: 'load',
|
|
||||||
autosave: 1,
|
|
||||||
xml: '<mxfile>...</mxfile>'
|
|
||||||
}), '*');
|
|
||||||
|
|
||||||
// Export diagram
|
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({
|
|
||||||
action: 'export',
|
|
||||||
format: 'xml'
|
|
||||||
}), '*');
|
|
||||||
```
|
|
||||||
|
|
||||||
### Events
|
|
||||||
- `init` - Editor is ready
|
|
||||||
- `autosave` - Diagram changed (auto-save)
|
|
||||||
- `export` - Export completed (returns XML)
|
|
||||||
- `save` - User clicked save
|
|
||||||
|
|
||||||
## Eksempel Data
|
|
||||||
|
|
||||||
### Features (6 stk.)
|
|
||||||
1. **Dashboard Forbedringer** (V1, færdig)
|
|
||||||
2. **Global Søgning** (V1, færdig)
|
|
||||||
3. **Settings & Brugerstyring** (V1, færdig)
|
|
||||||
4. **vTiger CRM Integration** (V2, planlagt)
|
|
||||||
5. **e-conomic Integration** (V2, planlagt)
|
|
||||||
6. **Rapport Generator** (V3, planlagt)
|
|
||||||
|
|
||||||
### Ideas (4 stk.)
|
|
||||||
1. **Eksport til Excel** (15 votes)
|
|
||||||
2. **Mobile app** (12 votes)
|
|
||||||
3. **AI Assistent** (8 votes)
|
|
||||||
4. **Dark mode forbedringer** (5 votes)
|
|
||||||
|
|
||||||
## Fjernelse af DEV Portal
|
|
||||||
|
|
||||||
Da dette er en selvstændig app, kan den nemt fjernes:
|
|
||||||
|
|
||||||
1. **Fjern fra main.py**:
|
|
||||||
```python
|
|
||||||
# Remove imports
|
|
||||||
from app.devportal.backend import router as devportal_api
|
|
||||||
from app.devportal.backend import views as devportal_views
|
|
||||||
|
|
||||||
# Remove router registrations
|
|
||||||
app.include_router(devportal_api.router, ...)
|
|
||||||
app.include_router(devportal_views.router, ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Fjern menu link** fra `app/shared/frontend/base.html`:
|
|
||||||
```html
|
|
||||||
<li><a class="dropdown-item py-2" href="/devportal">...</a></li>
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Slet filer**:
|
|
||||||
```bash
|
|
||||||
rm -rf app/devportal/
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Valgfri: Drop database tables**:
|
|
||||||
```sql
|
|
||||||
DROP TABLE IF EXISTS dev_workflows CASCADE;
|
|
||||||
DROP TABLE IF EXISTS dev_ideas CASCADE;
|
|
||||||
DROP TABLE IF EXISTS dev_features CASCADE;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fremtidige Forbedringer
|
|
||||||
|
|
||||||
- [ ] Drag-and-drop i Kanban board (flyt features mellem kolonner)
|
|
||||||
- [ ] Workflow thumbnails (PNG preview fra XML)
|
|
||||||
- [ ] Export roadmap til PDF eller Excel
|
|
||||||
- [ ] GitHub/Gitea integration (link features til commits/PRs)
|
|
||||||
- [ ] Kommentarer på features og idéer
|
|
||||||
- [ ] Notifikationer ved status ændringer
|
|
||||||
- [ ] Tidsregistrering per feature
|
|
||||||
- [ ] Sprint planning funktionalitet
|
|
||||||
- [ ] Access control (admin-only vs alle brugere)
|
|
||||||
|
|
||||||
## Teknisk Implementation
|
|
||||||
|
|
||||||
### Filer Oprettet
|
|
||||||
```
|
|
||||||
migrations/007_dev_portal.sql # Database schema
|
|
||||||
app/devportal/backend/router.py # API endpoints (17 stk.)
|
|
||||||
app/devportal/backend/views.py # Frontend routes (2 stk.)
|
|
||||||
app/devportal/frontend/portal.html # Hovedside (Kanban + Ideas + Workflows)
|
|
||||||
app/devportal/frontend/editor.html # Draw.io editor
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
- Bootstrap 5.3.2 (UI komponenter)
|
|
||||||
- Draw.io Embed (workflow editor)
|
|
||||||
- Fetch API (AJAX requests)
|
|
||||||
- Jinja2 (template rendering)
|
|
||||||
|
|
||||||
### Browser Support
|
|
||||||
- Chrome/Edge 90+
|
|
||||||
- Firefox 88+
|
|
||||||
- Safari 14+
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For problemer eller spørgsmål, kontakt udviklingsteamet eller opret en ny idé i DEV Portal.
|
|
||||||
@ -1,363 +0,0 @@
|
|||||||
# Kassekladde (Supplier Invoices) - BMC Hub
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
BMC Hub's kassekladde module enables management of supplier invoices (incoming invoices that the company must pay) with full integration to e-conomic accounting system via the journals/vouchers API.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
✅ **Complete CRUD Operations**
|
|
||||||
- Create, view, update, and delete supplier invoices
|
|
||||||
- Multi-line invoice support with VAT breakdown
|
|
||||||
- Vendor linking and automatic creation in e-conomic
|
|
||||||
|
|
||||||
✅ **Approval Workflow**
|
|
||||||
- Pending → Approved → Sent to e-conomic → Paid
|
|
||||||
- Approval tracking with user and timestamp
|
|
||||||
|
|
||||||
✅ **e-conomic Integration**
|
|
||||||
- Automatic supplier matching and creation
|
|
||||||
- Journal/voucher posting to kassekladde
|
|
||||||
- PDF attachment upload to vouchers
|
|
||||||
- Configurable journal number and default accounts
|
|
||||||
|
|
||||||
✅ **VAT Handling**
|
|
||||||
- Support for multiple VAT codes (I25, I0, IY25, etc.)
|
|
||||||
- Automatic VAT calculation per line
|
|
||||||
- VAT breakdown in e-conomic entries
|
|
||||||
|
|
||||||
✅ **Nordic Top UI**
|
|
||||||
- Modern, clean design
|
|
||||||
- Real-time statistics dashboard
|
|
||||||
- Filter and search functionality
|
|
||||||
- Responsive mobile support
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
|
|
||||||
**Main Tables:**
|
|
||||||
- `supplier_invoices` - Invoice headers with e-conomic tracking
|
|
||||||
- `supplier_invoice_lines` - Line items with VAT and account details
|
|
||||||
- `supplier_invoice_settings` - System configuration
|
|
||||||
- `vendors` - Supplier information with e-conomic IDs
|
|
||||||
|
|
||||||
**Views:**
|
|
||||||
- `overdue_supplier_invoices` - All overdue unpaid invoices
|
|
||||||
- `pending_economic_sync` - Approved invoices ready for e-conomic
|
|
||||||
|
|
||||||
### Backend Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
app/
|
|
||||||
billing/
|
|
||||||
backend/
|
|
||||||
supplier_invoices.py # FastAPI router with endpoints
|
|
||||||
frontend/
|
|
||||||
supplier_invoices.html # Nordic Top UI
|
|
||||||
views.py # Frontend routes
|
|
||||||
services/
|
|
||||||
economic_service.py # e-conomic API integration
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
|
|
||||||
**CRUD Operations:**
|
|
||||||
- `GET /api/v1/supplier-invoices` - List invoices with filters
|
|
||||||
- `GET /api/v1/supplier-invoices/{id}` - Get invoice details
|
|
||||||
- `POST /api/v1/supplier-invoices` - Create new invoice
|
|
||||||
- `PUT /api/v1/supplier-invoices/{id}` - Update invoice
|
|
||||||
- `DELETE /api/v1/supplier-invoices/{id}` - Delete invoice
|
|
||||||
|
|
||||||
**Workflow Actions:**
|
|
||||||
- `POST /api/v1/supplier-invoices/{id}/approve` - Approve invoice
|
|
||||||
- `POST /api/v1/supplier-invoices/{id}/send-to-economic` - Send to e-conomic
|
|
||||||
|
|
||||||
**Statistics:**
|
|
||||||
- `GET /api/v1/supplier-invoices/stats/overview` - Payment overview
|
|
||||||
- `GET /api/v1/supplier-invoices/stats/by-vendor` - Stats by vendor
|
|
||||||
|
|
||||||
**e-conomic Integration:**
|
|
||||||
- `GET /api/v1/supplier-invoices/economic/journals` - Available kassekladder
|
|
||||||
|
|
||||||
## Installation & Setup
|
|
||||||
|
|
||||||
### 1. Database Migration
|
|
||||||
|
|
||||||
Run the migration to create tables:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Execute migration SQL
|
|
||||||
psql -U bmc_hub -d bmc_hub < migrations/008_supplier_invoices.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
Or via Docker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose exec postgres psql -U bmc_hub -d bmc_hub < /app/migrations/008_supplier_invoices.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Configure e-conomic Credentials
|
|
||||||
|
|
||||||
Add to `.env` file:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# e-conomic Integration
|
|
||||||
ECONOMIC_API_URL=https://restapi.e-conomic.com
|
|
||||||
ECONOMIC_APP_SECRET_TOKEN=your_app_secret_token
|
|
||||||
ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token
|
|
||||||
|
|
||||||
# Safety switches (ALWAYS start with these enabled)
|
|
||||||
ECONOMIC_READ_ONLY=true
|
|
||||||
ECONOMIC_DRY_RUN=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Configure Default Settings
|
|
||||||
|
|
||||||
The default journal number and accounts can be configured via database:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Update default kassekladde number
|
|
||||||
UPDATE supplier_invoice_settings
|
|
||||||
SET setting_value = '1'
|
|
||||||
WHERE setting_key = 'economic_default_journal';
|
|
||||||
|
|
||||||
-- Update default expense account
|
|
||||||
UPDATE supplier_invoice_settings
|
|
||||||
SET setting_value = '5810'
|
|
||||||
WHERE setting_key = 'economic_default_contra_account';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Restart Application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose restart api
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Guide
|
|
||||||
|
|
||||||
### Creating a Supplier Invoice
|
|
||||||
|
|
||||||
1. Navigate to `/billing/supplier-invoices`
|
|
||||||
2. Click "Ny Faktura" button
|
|
||||||
3. Fill in required fields:
|
|
||||||
- Invoice number (from supplier)
|
|
||||||
- Vendor (select from dropdown)
|
|
||||||
- Invoice date
|
|
||||||
- Total amount (incl. VAT)
|
|
||||||
4. Add line items with:
|
|
||||||
- Description
|
|
||||||
- Quantity & price
|
|
||||||
- VAT code (25%, 0%, reverse charge, etc.)
|
|
||||||
5. Click "Gem" to save
|
|
||||||
|
|
||||||
### Approval Workflow
|
|
||||||
|
|
||||||
**Status Flow:**
|
|
||||||
```
|
|
||||||
pending → approved → sent_to_economic → paid
|
|
||||||
```
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Invoice created → Status: `pending`
|
|
||||||
2. Review and approve → Status: `approved`
|
|
||||||
3. Send to e-conomic → Status: `sent_to_economic` (voucher created)
|
|
||||||
4. Mark as paid → Status: `paid`
|
|
||||||
|
|
||||||
### Sending to e-conomic
|
|
||||||
|
|
||||||
**Prerequisites:**
|
|
||||||
- Invoice must be `approved`
|
|
||||||
- Vendor must exist (auto-created if needed)
|
|
||||||
- At least one line item
|
|
||||||
|
|
||||||
**Process:**
|
|
||||||
1. Click "Send til e-conomic" button
|
|
||||||
2. System will:
|
|
||||||
- Check/create vendor in e-conomic
|
|
||||||
- Build VAT breakdown from lines
|
|
||||||
- Create journal voucher entry
|
|
||||||
- Upload PDF attachment (if available)
|
|
||||||
- Update invoice with voucher number
|
|
||||||
|
|
||||||
**Result:**
|
|
||||||
- Voucher created in e-conomic kassekladde
|
|
||||||
- Invoice status → `sent_to_economic`
|
|
||||||
- Voucher number stored for reference
|
|
||||||
|
|
||||||
## e-conomic Integration Details
|
|
||||||
|
|
||||||
### Safety Modes
|
|
||||||
|
|
||||||
**READ_ONLY Mode** (default: `true`)
|
|
||||||
- Blocks ALL write operations to e-conomic
|
|
||||||
- Only GET requests allowed
|
|
||||||
- Use for testing API connection
|
|
||||||
|
|
||||||
**DRY_RUN Mode** (default: `true`)
|
|
||||||
- Logs all operations but doesn't send to e-conomic
|
|
||||||
- Full payload preview in logs
|
|
||||||
- Safe for development/testing
|
|
||||||
|
|
||||||
**Production Mode** (both `false`)
|
|
||||||
- Actually sends data to e-conomic
|
|
||||||
- ⚠️ **Use with caution!**
|
|
||||||
- Always test with dry-run first
|
|
||||||
|
|
||||||
### Journal Voucher Structure
|
|
||||||
|
|
||||||
e-conomic vouchers use this format:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"accountingYear": {"year": "2025"},
|
|
||||||
"journal": {"journalNumber": 1},
|
|
||||||
"entries": {
|
|
||||||
"supplierInvoices": [
|
|
||||||
{
|
|
||||||
"supplier": {"supplierNumber": 123},
|
|
||||||
"amount": 1250.00,
|
|
||||||
"contraAccount": {"accountNumber": 5810},
|
|
||||||
"currency": {"code": "DKK"},
|
|
||||||
"date": "2025-12-06",
|
|
||||||
"dueDate": "2026-01-05",
|
|
||||||
"supplierInvoiceNumber": "INV-12345",
|
|
||||||
"text": "Invoice description",
|
|
||||||
"contraVatAccount": {"vatCode": "I25"},
|
|
||||||
"contraVatAmount": 250.00
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### VAT Code Mapping
|
|
||||||
|
|
||||||
| VAT Code | Description | Rate | Use Case |
|
|
||||||
|----------|-------------|------|----------|
|
|
||||||
| `I25` | Indenlandsk købsmoms 25% | 25% | Standard Danish purchases |
|
|
||||||
| `I0` | Momsfri køb | 0% | VAT exempt |
|
|
||||||
| `IY25` | Omvendt betalingspligt 25% | 25% | Reverse charge |
|
|
||||||
| `IYEU` | Omvendt EU | 0% | EU reverse charge |
|
|
||||||
| `IVEU` | Erhvervelse EU | 25% | EU acquisition |
|
|
||||||
|
|
||||||
### Account Number Mapping
|
|
||||||
|
|
||||||
Default expense accounts (can be customized per line):
|
|
||||||
|
|
||||||
- `5810` - Drift og materialer (default)
|
|
||||||
- `5820` - IT og software
|
|
||||||
- `5830` - Telefoni og internet
|
|
||||||
- `5840` - Kontorartikler
|
|
||||||
- `6000` - Løn og honorarer
|
|
||||||
|
|
||||||
## Development Guide
|
|
||||||
|
|
||||||
### Adding New Features
|
|
||||||
|
|
||||||
**1. Backend Endpoint:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# app/billing/backend/supplier_invoices.py
|
|
||||||
@router.post("/supplier-invoices/{invoice_id}/custom-action")
|
|
||||||
async def custom_action(invoice_id: int):
|
|
||||||
# Your logic here
|
|
||||||
return {"success": True}
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Frontend Integration:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// supplier_invoices.html
|
|
||||||
async function customAction(invoiceId) {
|
|
||||||
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/custom-action`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
// Handle response
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing e-conomic Integration
|
|
||||||
|
|
||||||
**1. Test Connection:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
from app.services.economic_service import get_economic_service
|
|
||||||
|
|
||||||
economic = get_economic_service()
|
|
||||||
result = await economic.test_connection()
|
|
||||||
# Should return True if credentials are valid
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Test Dry-Run Mode:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In .env
|
|
||||||
ECONOMIC_READ_ONLY=false
|
|
||||||
ECONOMIC_DRY_RUN=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Then create and approve an invoice, send to e-conomic. Check logs for full payload without actually posting.
|
|
||||||
|
|
||||||
**3. Production Test:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# WARNING: This will create real data in e-conomic!
|
|
||||||
ECONOMIC_READ_ONLY=false
|
|
||||||
ECONOMIC_DRY_RUN=false
|
|
||||||
```
|
|
||||||
|
|
||||||
Start with a small test invoice to verify everything works.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: "No journals found"
|
|
||||||
|
|
||||||
**Solution:** Check e-conomic credentials and ensure user has access to journals/kassekladder.
|
|
||||||
|
|
||||||
### Issue: "Supplier not found in e-conomic"
|
|
||||||
|
|
||||||
**Solution:** System will auto-create supplier if `ECONOMIC_DRY_RUN=false`. Verify vendor name is correct.
|
|
||||||
|
|
||||||
### Issue: "VAT validation failed"
|
|
||||||
|
|
||||||
**Solution:** Ensure VAT codes match e-conomic settings. Check `vat_code` in line items (I25, I0, etc.).
|
|
||||||
|
|
||||||
### Issue: "Voucher creation failed"
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Check e-conomic API logs in application logs
|
|
||||||
2. Verify journal number exists in e-conomic
|
|
||||||
3. Ensure all required fields are present (supplier, amount, date)
|
|
||||||
4. Check contra account number is valid
|
|
||||||
|
|
||||||
## Integration with OmniSync
|
|
||||||
|
|
||||||
This module is based on OmniSync's proven kassekladde implementation with the following enhancements:
|
|
||||||
|
|
||||||
- ✅ PostgreSQL instead of SQLite
|
|
||||||
- ✅ Nordic Top design instead of custom CSS
|
|
||||||
- ✅ Integrated with BMC Hub's vendor system
|
|
||||||
- ✅ Simplified approval workflow
|
|
||||||
- ✅ Better error handling and logging
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- **e-conomic API Docs:** https://restdocs.e-conomic.com/
|
|
||||||
- **Journals API:** https://restdocs.e-conomic.com/#journals
|
|
||||||
- **Vouchers API:** https://restdocs.e-conomic.com/#vouchers
|
|
||||||
- **Suppliers API:** https://restdocs.e-conomic.com/#suppliers
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions:
|
|
||||||
1. Check application logs: `docker-compose logs -f api`
|
|
||||||
2. Review e-conomic API response in logs
|
|
||||||
3. Test with DRY_RUN mode first
|
|
||||||
4. Contact system administrator
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** December 6, 2025
|
|
||||||
**Version:** 1.0.0
|
|
||||||
**Maintained by:** BMC Networks Development Team
|
|
||||||
@ -1,315 +0,0 @@
|
|||||||
# Perfect Template Creation Flow - BMC Hub
|
|
||||||
|
|
||||||
## 🎯 Workflow Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
Upload Invoice
|
|
||||||
↓
|
|
||||||
No Template Match? → AI Extracts (10s)
|
|
||||||
↓
|
|
||||||
"Opret Template" knap vises
|
|
||||||
↓
|
|
||||||
Click → Auto-creates template
|
|
||||||
↓
|
|
||||||
Next invoice: 0.1s (100x faster!)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📋 Step-by-Step Guide
|
|
||||||
|
|
||||||
### Method 1: From Upload (Recommended for First-Time)
|
|
||||||
|
|
||||||
1. **Upload faktura** via `/billing/supplier-invoices`
|
|
||||||
2. If no template matches:
|
|
||||||
- AI extracts data (10s)
|
|
||||||
- **"🪄 Opret Template" knap** vises
|
|
||||||
3. Click "Opret Template"
|
|
||||||
- System AI-analyzes PDF
|
|
||||||
- Converts to template format
|
|
||||||
- Creates template automatically
|
|
||||||
4. **Redirect to Template Builder** for fine-tuning (optional)
|
|
||||||
|
|
||||||
**Benefits:**
|
|
||||||
- ✅ One-click from upload success
|
|
||||||
- ✅ Uses real invoice data
|
|
||||||
- ✅ Automatic field mapping
|
|
||||||
- ✅ Ready to use immediately
|
|
||||||
|
|
||||||
### Method 2: Template Builder Manual
|
|
||||||
|
|
||||||
1. Go to `/billing/template-builder`
|
|
||||||
2. **Step 1:** Select PDF file
|
|
||||||
3. **Step 2:** Choose vendor + name template
|
|
||||||
4. **Step 3:** Click **"🤖 AI Auto-generer Template"**
|
|
||||||
- AI analyzes PDF
|
|
||||||
- Auto-fills all patterns
|
|
||||||
- Shows detection confidence
|
|
||||||
5. **Step 4:** Test & Save
|
|
||||||
|
|
||||||
**Benefits:**
|
|
||||||
- ✅ More control
|
|
||||||
- ✅ Preview before saving
|
|
||||||
- ✅ Can edit patterns manually
|
|
||||||
- ✅ Test against PDF first
|
|
||||||
|
|
||||||
### Method 3: Templates List (Batch)
|
|
||||||
|
|
||||||
1. Go to `/billing/templates`
|
|
||||||
2. View existing templates
|
|
||||||
3. Click "Test" to validate
|
|
||||||
4. Create new from scratch
|
|
||||||
|
|
||||||
## 🔄 Complete User Journey
|
|
||||||
|
|
||||||
### First Invoice from New Vendor
|
|
||||||
|
|
||||||
```
|
|
||||||
User uploads ALSO invoice #1
|
|
||||||
↓
|
|
||||||
System: "Ingen template match"
|
|
||||||
↓
|
|
||||||
AI extracts in 10s
|
|
||||||
↓
|
|
||||||
Shows: "✅ Faktura uploadet!"
|
|
||||||
"⚠️ Ingen template - næste gang vil være langsom"
|
|
||||||
[🪄 Opret Template knap]
|
|
||||||
↓
|
|
||||||
User clicks "Opret Template"
|
|
||||||
↓
|
|
||||||
System creates template automatically
|
|
||||||
↓
|
|
||||||
"✅ Template oprettet! Næste faktura vil være 100x hurtigere"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Second Invoice from Same Vendor
|
|
||||||
|
|
||||||
```
|
|
||||||
User uploads ALSO invoice #2
|
|
||||||
↓
|
|
||||||
Template matches (0.1s) ⚡
|
|
||||||
↓
|
|
||||||
"✅ Faktura uploadet - auto-matched template!"
|
|
||||||
NO template creation button (already exists)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎨 UI/UX Details
|
|
||||||
|
|
||||||
### Upload Success Message (No Template Match)
|
|
||||||
|
|
||||||
```html
|
|
||||||
✅ Faktura Uploadet & Analyseret!
|
|
||||||
|
|
||||||
AI Udtrækning: [Vendor Badge] [Confidence Badge]
|
|
||||||
Leverandør: ALSO A/S
|
|
||||||
CVR: 17630903
|
|
||||||
Fakturanr: 974733485
|
|
||||||
Beløb: 5.165,61 DKK
|
|
||||||
|
|
||||||
⚠️ Ingen template match - fremtidige uploads vil være langsomme
|
|
||||||
|
|
||||||
[👁️ Vis Faktura] [🪄 Opret Template] [✅ Luk]
|
|
||||||
```
|
|
||||||
|
|
||||||
### After Template Creation
|
|
||||||
|
|
||||||
```html
|
|
||||||
✅ Template Oprettet!
|
|
||||||
|
|
||||||
Næste gang en faktura fra denne leverandør uploades, vil den blive
|
|
||||||
behandlet automatisk på 0.1 sekunder i stedet for 10 sekunder!
|
|
||||||
|
|
||||||
Template ID: 42
|
|
||||||
Fields: vendor_cvr, invoice_number, invoice_date, total_amount
|
|
||||||
Detection patterns: 3
|
|
||||||
|
|
||||||
[✏️ Rediger Template] [📋 Se Alle Templates] [✅ Luk]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Performance Metrics
|
|
||||||
|
|
||||||
| Scenario | Processing Time | User Action |
|
|
||||||
|----------|----------------|-------------|
|
|
||||||
| **No Template** | 10s (AI) | Manual → "Opret Template" |
|
|
||||||
| **Template Match** | 0.1s (Regex) | None - automatic |
|
|
||||||
| **Template Creation** | 15s total | One-click |
|
|
||||||
|
|
||||||
**ROI Example:**
|
|
||||||
- 100 invoices from same vendor
|
|
||||||
- Without template: 100 × 10s = **16.7 minutes**
|
|
||||||
- With template: 1 × 15s + 99 × 0.1s = **~25 seconds**
|
|
||||||
- **Time saved: 16 minutes!**
|
|
||||||
|
|
||||||
## 🧠 AI Auto-Generate Details
|
|
||||||
|
|
||||||
### Input
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
pdf_text: "ALSO A/S\nNummer 974733485\n...",
|
|
||||||
vendor_id: 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### AI Output (qwen2.5:3b - 10s)
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"vendor_cvr": "17630903",
|
|
||||||
"invoice_number": "974733485",
|
|
||||||
"invoice_date": "30.06.2025",
|
|
||||||
"total_amount": "5165.61",
|
|
||||||
"detection_patterns": ["ALSO A/S", "Mårkærvej 2", "Faktura"],
|
|
||||||
"lines_start": "Position Varenr. Beskrivelse",
|
|
||||||
"lines_end": "Subtotal"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Converted to Template Format
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"vendor_id": 1,
|
|
||||||
"template_name": "Auto-generated 2025-12-07",
|
|
||||||
"detection_patterns": [
|
|
||||||
{"type": "text", "pattern": "ALSO A/S", "weight": 0.5},
|
|
||||||
{"type": "text", "pattern": "Mårkærvej 2", "weight": 0.3}
|
|
||||||
],
|
|
||||||
"field_mappings": {
|
|
||||||
"vendor_cvr": {"pattern": "DK\\s*(\\d{8})", "group": 1},
|
|
||||||
"invoice_number": {"pattern": "Nummer\\s*(\\d+)", "group": 1},
|
|
||||||
"invoice_date": {"pattern": "Dato\\s*(\\d{1,2}[\\/.-]\\d{1,2}[\\/.-]\\d{4})", "group": 1},
|
|
||||||
"total_amount": {"pattern": "Total\\s*([\\d.,]+)", "group": 1},
|
|
||||||
"lines_start": {"pattern": "Position Varenr\\. Beskrivelse"},
|
|
||||||
"lines_end": {"pattern": "Subtotal"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✨ Smart Features
|
|
||||||
|
|
||||||
### 1. Auto-Detection of Template Need
|
|
||||||
```javascript
|
|
||||||
if (!result.template_matched && result.vendor_id) {
|
|
||||||
showButton("Opret Template"); // Only if vendor exists
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. One-Click Creation
|
|
||||||
- Fetches PDF text
|
|
||||||
- AI analyzes
|
|
||||||
- Converts format
|
|
||||||
- Saves template
|
|
||||||
- Shows success with edit link
|
|
||||||
|
|
||||||
### 3. Field Mapping Intelligence
|
|
||||||
```javascript
|
|
||||||
// Handles both nested and flat AI responses
|
|
||||||
const cvrValue = aiData.vendor_cvr?.value || aiData.vendor_cvr || aiData.cvr;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Multi-line Item Support
|
|
||||||
- NO line_pattern in template
|
|
||||||
- Uses smart multi-line extraction
|
|
||||||
- Combines description + price lines automatically
|
|
||||||
|
|
||||||
## 🔧 Technical Implementation
|
|
||||||
|
|
||||||
### Frontend Flow
|
|
||||||
```javascript
|
|
||||||
async function createTemplateFromInvoice(invoiceId, vendorId) {
|
|
||||||
// 1. Get PDF text
|
|
||||||
const pdfData = await reprocess(invoiceId);
|
|
||||||
|
|
||||||
// 2. AI analyze
|
|
||||||
const aiData = await aiAnalyze(pdfData.pdf_text, vendorId);
|
|
||||||
|
|
||||||
// 3. Convert to template format
|
|
||||||
const template = convertAiToTemplate(aiData, vendorId);
|
|
||||||
|
|
||||||
// 4. Save
|
|
||||||
await createTemplate(template);
|
|
||||||
|
|
||||||
// 5. Show success + edit link
|
|
||||||
showSuccess(template.template_id);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backend Endpoints
|
|
||||||
```
|
|
||||||
POST /api/v1/supplier-invoices/reprocess/{id}
|
|
||||||
→ Returns: {pdf_text, ...}
|
|
||||||
|
|
||||||
POST /api/v1/supplier-invoices/ai-analyze
|
|
||||||
→ Input: {pdf_text, vendor_id}
|
|
||||||
→ Returns: AI extracted fields
|
|
||||||
|
|
||||||
POST /api/v1/supplier-invoices/templates
|
|
||||||
→ Input: {vendor_id, template_name, detection_patterns, field_mappings}
|
|
||||||
→ Returns: {template_id, ...}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Success Metrics
|
|
||||||
|
|
||||||
Track these in template_usage_log:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
template_id,
|
|
||||||
COUNT(*) as uses,
|
|
||||||
AVG(CASE WHEN matched THEN 1 ELSE 0 END) as success_rate,
|
|
||||||
AVG(confidence) as avg_confidence
|
|
||||||
FROM template_usage_log
|
|
||||||
GROUP BY template_id
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Best Practices
|
|
||||||
|
|
||||||
### For Users
|
|
||||||
1. **Always create template** after first invoice from new vendor
|
|
||||||
2. **Test template** with 2-3 invoices before trusting
|
|
||||||
3. **Edit patterns** if confidence < 80%
|
|
||||||
4. **Use descriptive names**: "ALSO Standard", "ALSO Email Format"
|
|
||||||
|
|
||||||
### For Admins
|
|
||||||
1. Review auto-generated templates weekly
|
|
||||||
2. Merge duplicate templates (same vendor, similar patterns)
|
|
||||||
3. Disable low-performing templates (success_rate < 0.7)
|
|
||||||
4. Keep AI model updated (qwen2.5:3b or better)
|
|
||||||
|
|
||||||
## 🚨 Edge Cases Handled
|
|
||||||
|
|
||||||
### Vendor Not Found
|
|
||||||
- AI extracts CVR but vendor doesn't exist in DB
|
|
||||||
- Shows warning: "Du skal oprette leverandør først"
|
|
||||||
- No template creation button (needs vendor_id)
|
|
||||||
|
|
||||||
### AI Returns Incomplete Data
|
|
||||||
- Template created with available fields only
|
|
||||||
- Missing fields can be added manually later
|
|
||||||
- Template still speeds up future processing
|
|
||||||
|
|
||||||
### Duplicate Templates
|
|
||||||
- System allows multiple templates per vendor
|
|
||||||
- Each can target different invoice formats
|
|
||||||
- Detection patterns differentiate them
|
|
||||||
|
|
||||||
## 🎓 Training Users
|
|
||||||
|
|
||||||
### Quick Start Tutorial
|
|
||||||
```
|
|
||||||
1. Upload en faktura
|
|
||||||
2. Klik "Opret Template" når den vises
|
|
||||||
3. Næste gang = automatisk!
|
|
||||||
```
|
|
||||||
|
|
||||||
### Power User Tips
|
|
||||||
```
|
|
||||||
- Brug Template Builder for bedre kontrol
|
|
||||||
- Test templates før production
|
|
||||||
- Kombiner AI + manual editing
|
|
||||||
- Gennemgå templates månedligt
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📈 Future Enhancements
|
|
||||||
|
|
||||||
1. **Batch Template Creation**: Upload 10 PDFs → Create 10 templates
|
|
||||||
2. **Template Suggestions**: "Found similar template - use this instead?"
|
|
||||||
3. **Auto-Merge**: Detect duplicate templates and suggest merge
|
|
||||||
4. **Confidence Tracking**: Dashboard showing template performance
|
|
||||||
5. **A/B Testing**: Test pattern variations automatically
|
|
||||||
@ -1,213 +0,0 @@
|
|||||||
# AI Template Generation - Perfect Prompt Example
|
|
||||||
|
|
||||||
## Prompt til Ollama/LLM
|
|
||||||
|
|
||||||
```
|
|
||||||
OPGAVE: Analyser denne danske faktura og udtræk information til template-generering.
|
|
||||||
|
|
||||||
RETURNER KUN VALID JSON - ingen forklaring, ingen markdown, kun ren JSON!
|
|
||||||
|
|
||||||
REQUIRED JSON STRUKTUR:
|
|
||||||
{
|
|
||||||
"vendor_cvr": {
|
|
||||||
"value": "17630903",
|
|
||||||
"pattern": "DK\\s*(\\d{8})",
|
|
||||||
"group": 1
|
|
||||||
},
|
|
||||||
"invoice_number": {
|
|
||||||
"value": "974733485",
|
|
||||||
"pattern": "Nummer\\s*(\\d+)",
|
|
||||||
"group": 1
|
|
||||||
},
|
|
||||||
"invoice_date": {
|
|
||||||
"value": "30.06.2025",
|
|
||||||
"pattern": "Dato\\s*(\\d{1,2}[\\/.\\-]\\d{1,2}[\\/.\\-]\\d{4})",
|
|
||||||
"group": 1,
|
|
||||||
"format": "DD.MM.YYYY"
|
|
||||||
},
|
|
||||||
"total_amount": {
|
|
||||||
"value": "5.165,61",
|
|
||||||
"pattern": "Total\\s*([\\d.,]+)",
|
|
||||||
"group": 1
|
|
||||||
},
|
|
||||||
"detection_patterns": [
|
|
||||||
{"type": "text", "pattern": "ALSO A/S", "weight": 0.5},
|
|
||||||
{"type": "text", "pattern": "Mårkærvej 2", "weight": 0.3},
|
|
||||||
{"type": "text", "pattern": "Faktura", "weight": 0.2}
|
|
||||||
],
|
|
||||||
"lines_start": {
|
|
||||||
"pattern": "Position Varenr\\. Beskrivelse Antal/Enhed"
|
|
||||||
},
|
|
||||||
"lines_end": {
|
|
||||||
"pattern": "Subtotal|I alt ekskl\\. moms"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
REGLER:
|
|
||||||
1. Pattern skal være regex med escaped backslashes (\\s, \\d)
|
|
||||||
2. Group angiver hvilken gruppe i regex der skal udtrækkes (1-baseret)
|
|
||||||
3. Value skal være den faktiske værdi fundet i dokumentet
|
|
||||||
4. Detection_patterns skal være 3-5 unikke tekststrenge der identificerer leverandøren
|
|
||||||
5. lines_start er teksten LIGE FØR varelinjer starter
|
|
||||||
6. lines_end er teksten EFTER varelinjer slutter
|
|
||||||
7. LAV IKKE line_pattern - systemet bruger automatisk multi-line extraction
|
|
||||||
|
|
||||||
PDF TEKST:
|
|
||||||
[PDF_CONTENT_HER]
|
|
||||||
|
|
||||||
RETURNER KUN JSON - intet andet!
|
|
||||||
```
|
|
||||||
|
|
||||||
## Eksempel Response (det du skal få tilbage)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"vendor_cvr": {
|
|
||||||
"value": "17630903",
|
|
||||||
"pattern": "DK\\s*(\\d{8})",
|
|
||||||
"group": 1
|
|
||||||
},
|
|
||||||
"invoice_number": {
|
|
||||||
"value": "974733485",
|
|
||||||
"pattern": "Nummer\\s*(\\d+)",
|
|
||||||
"group": 1
|
|
||||||
},
|
|
||||||
"invoice_date": {
|
|
||||||
"value": "30.06.2025",
|
|
||||||
"pattern": "Dato\\s*(\\d{1,2}[\\/.\\-]\\d{1,2}[\\/.\\-]\\d{4})",
|
|
||||||
"group": 1,
|
|
||||||
"format": "DD.MM.YYYY"
|
|
||||||
},
|
|
||||||
"total_amount": {
|
|
||||||
"value": "5.165,61",
|
|
||||||
"pattern": "beløb\\s*([\\d.,]+)",
|
|
||||||
"group": 1
|
|
||||||
},
|
|
||||||
"detection_patterns": [
|
|
||||||
{"type": "text", "pattern": "ALSO A/S", "weight": 0.5},
|
|
||||||
{"type": "text", "pattern": "Mårkærvej 2", "weight": 0.3},
|
|
||||||
{"type": "text", "pattern": "DK-2630 Taastrup", "weight": 0.2}
|
|
||||||
],
|
|
||||||
"lines_start": {
|
|
||||||
"pattern": "Position Varenr\\. Beskrivelse Antal/Enhed Pris pr\\. enhed Total pris"
|
|
||||||
},
|
|
||||||
"lines_end": {
|
|
||||||
"pattern": "Subtotal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hvordan bruges det i kode
|
|
||||||
|
|
||||||
```python
|
|
||||||
import json
|
|
||||||
import requests
|
|
||||||
|
|
||||||
pdf_text = "ALSO A/S\nMårkærvej 2\n2630 Taastrup\nNummer 974733485..."
|
|
||||||
|
|
||||||
prompt = f"""OPGAVE: Analyser denne danske faktura og udtræk information til template-generering.
|
|
||||||
|
|
||||||
RETURNER KUN VALID JSON - ingen forklaring, kun JSON!
|
|
||||||
|
|
||||||
REQUIRED JSON STRUKTUR:
|
|
||||||
{{
|
|
||||||
"vendor_cvr": {{"value": "17630903", "pattern": "DK\\\\s*(\\\\d{{8}})", "group": 1}},
|
|
||||||
"invoice_number": {{"value": "974733485", "pattern": "Nummer\\\\s*(\\\\d+)", "group": 1}},
|
|
||||||
"invoice_date": {{"value": "30.06.2025", "pattern": "Dato\\\\s*(\\\\d{{1,2}}[\\\\/.\\\\-]\\\\d{{1,2}}[\\\\/.\\\\-]\\\\d{{4}})", "group": 1}},
|
|
||||||
"total_amount": {{"value": "5.165,61", "pattern": "Total\\\\s*([\\\\d.,]+)", "group": 1}},
|
|
||||||
"detection_patterns": [{{"type": "text", "pattern": "ALSO A/S", "weight": 0.5}}],
|
|
||||||
"lines_start": {{"pattern": "Position Varenr"}},
|
|
||||||
"lines_end": {{"pattern": "Subtotal"}}
|
|
||||||
}}
|
|
||||||
|
|
||||||
PDF TEKST:
|
|
||||||
{pdf_text[:2000]}
|
|
||||||
|
|
||||||
RETURNER KUN JSON!"""
|
|
||||||
|
|
||||||
# Send til Ollama
|
|
||||||
response = requests.post('http://localhost:11434/api/generate', json={
|
|
||||||
'model': 'llama3.2',
|
|
||||||
'prompt': prompt,
|
|
||||||
'stream': False,
|
|
||||||
'options': {'temperature': 0.1}
|
|
||||||
})
|
|
||||||
|
|
||||||
result = json.loads(response.json()['response'])
|
|
||||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test via curl
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Hent PDF tekst
|
|
||||||
PDF_TEXT=$(curl -s -X POST http://localhost:8001/api/v1/supplier-invoices/reprocess/4 | jq -r '.pdf_text')
|
|
||||||
|
|
||||||
# Send til AI endpoint
|
|
||||||
curl -X POST http://localhost:8001/api/v1/supplier-invoices/ai-analyze \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"pdf_text\": \"$PDF_TEXT\", \"vendor_id\": 1}" | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tips for bedste resultater
|
|
||||||
|
|
||||||
1. **Brug temperature 0.1** - For konsistente JSON responses
|
|
||||||
2. **Escaping**: Brug `\\\\s` i Python strings (bliver til `\\s` i JSON, `\s` i regex)
|
|
||||||
3. **Specificer format**: Vis eksempel-output i prompten
|
|
||||||
4. **Vis struktur**: Giv klar JSON struktur med alle required felter
|
|
||||||
5. **Begræns tekst**: Kun første 2000 tegn (indeholder det vigtigste)
|
|
||||||
6. **Validation**: Check at response er valid JSON før brug
|
|
||||||
|
|
||||||
## Konvertering til template format
|
|
||||||
|
|
||||||
AI returnerer nested format, men template vil have flat format:
|
|
||||||
|
|
||||||
```python
|
|
||||||
ai_result = {
|
|
||||||
"vendor_cvr": {"value": "17630903", "pattern": "DK\\s*(\\d{8})", "group": 1},
|
|
||||||
"invoice_number": {"value": "974733485", "pattern": "Nummer\\s*(\\d+)", "group": 1}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Konverter til template field_mappings
|
|
||||||
field_mappings = {}
|
|
||||||
for field_name, config in ai_result.items():
|
|
||||||
if field_name != 'detection_patterns':
|
|
||||||
field_mappings[field_name] = {
|
|
||||||
'pattern': config['pattern'],
|
|
||||||
'group': config.get('group', 1)
|
|
||||||
}
|
|
||||||
if 'format' in config:
|
|
||||||
field_mappings[field_name]['format'] = config['format']
|
|
||||||
```
|
|
||||||
|
|
||||||
## Forventet output format
|
|
||||||
|
|
||||||
Template systemet forventer:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"vendor_id": 1,
|
|
||||||
"template_name": "ALSO A/S",
|
|
||||||
"detection_patterns": [
|
|
||||||
{"type": "text", "pattern": "ALSO A/S", "weight": 0.5}
|
|
||||||
],
|
|
||||||
"field_mappings": {
|
|
||||||
"vendor_cvr": {
|
|
||||||
"pattern": "DK\\s*(\\d{8})",
|
|
||||||
"group": 1
|
|
||||||
},
|
|
||||||
"invoice_number": {
|
|
||||||
"pattern": "Nummer\\s*(\\d+)",
|
|
||||||
"group": 1
|
|
||||||
},
|
|
||||||
"lines_start": {
|
|
||||||
"pattern": "Position Varenr"
|
|
||||||
},
|
|
||||||
"lines_end": {
|
|
||||||
"pattern": "Subtotal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
VIGTIGT: Ingen `line_item` pattern - systemet bruger automatisk multi-line extraction!
|
|
||||||
@ -1,562 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="da">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Design System & Komponenter</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--accent: #0f4c75;
|
|
||||||
--accent-light: #eef2f5;
|
|
||||||
--text-primary: #2c3e50;
|
|
||||||
--text-secondary: #6c757d;
|
|
||||||
--bg-body: #f8f9fa;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navbar Styling */
|
|
||||||
.navbar {
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
color: var(--accent);
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card Styling */
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.03);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
background-color: transparent;
|
|
||||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
|
||||||
padding: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: #0b3a5b;
|
|
||||||
border-color: #0b3a5b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-primary {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-primary:hover {
|
|
||||||
background-color: var(--accent);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section Headers */
|
|
||||||
.section-title {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-swatch {
|
|
||||||
width: 100%;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<!-- Top Navigation -->
|
|
||||||
<nav class="navbar navbar-expand-lg sticky-top">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="#">
|
|
||||||
<i class="bi bi-grid-3x3-gap-fill me-2"></i>BMC Hub
|
|
||||||
</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav me-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="index.html">Dashboard</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="customers.html">Kunder</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" href="components.html">Design System</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<div class="dropdown">
|
|
||||||
<a href="#" class="d-flex align-items-center text-decoration-none dropdown-toggle text-dark" data-bs-toggle="dropdown">
|
|
||||||
<div class="bg-light rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
|
|
||||||
<i class="bi bi-person-fill text-secondary"></i>
|
|
||||||
</div>
|
|
||||||
<span class="small fw-medium">Admin</span>
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end border-0 shadow-sm">
|
|
||||||
<li><a class="dropdown-item" href="settings.html"><i class="bi bi-gear me-2"></i>Indstillinger</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item text-danger" href="login.html"><i class="bi bi-box-arrow-right me-2"></i>Log ud</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="container-fluid py-4">
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h1 class="h3 fw-bold text-dark">Design System & Komponenter</h1>
|
|
||||||
<p class="text-secondary">En komplet oversigt over alle UI elementer i "Nordic Top" temaet.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Farver & Typografi -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="section-title">Farver & Typografi</h6>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-header">Farvepalette</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="color-swatch" style="background-color: #0f4c75;">#0f4c75</div>
|
|
||||||
<div class="small text-center fw-bold">Accent</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="color-swatch text-dark" style="background-color: #eef2f5;">#eef2f5</div>
|
|
||||||
<div class="small text-center fw-bold">Accent Light</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="color-swatch" style="background-color: #2c3e50;">#2c3e50</div>
|
|
||||||
<div class="small text-center fw-bold">Text Primary</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="color-swatch" style="background-color: #6c757d;">#6c757d</div>
|
|
||||||
<div class="small text-center fw-bold">Text Secondary</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="color-swatch" style="background-color: #198754;">#198754</div>
|
|
||||||
<div class="small text-center fw-bold">Success</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="color-swatch" style="background-color: #ffc107; color: #000;">#ffc107</div>
|
|
||||||
<div class="small text-center fw-bold">Warning</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="color-swatch" style="background-color: #dc3545;">#dc3545</div>
|
|
||||||
<div class="small text-center fw-bold">Danger</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="color-swatch" style="background-color: #0dcaf0;">#0dcaf0</div>
|
|
||||||
<div class="small text-center fw-bold">Info</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-header">Typografi</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<h1>H1 Overskrift (2.5rem)</h1>
|
|
||||||
<h2>H2 Overskrift (2rem)</h2>
|
|
||||||
<h3>H3 Overskrift (1.75rem)</h3>
|
|
||||||
<h4>H4 Overskrift (1.5rem)</h4>
|
|
||||||
<h5>H5 Overskrift (1.25rem)</h5>
|
|
||||||
<h6>H6 Overskrift (1rem)</h6>
|
|
||||||
<hr>
|
|
||||||
<p class="lead">Dette er en lead paragraph, der bruges til intro tekst.</p>
|
|
||||||
<p>Dette er almindelig brødtekst. Lorem ipsum dolor sit amet, consectetur adipiscing elit. <small class="text-muted">Dette er lille tekst.</small></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Knapper & Badges -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="section-title">Knapper & Badges</h6>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Knapper</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<button type="button" class="btn btn-primary">Primary</button>
|
|
||||||
<button type="button" class="btn btn-secondary">Secondary</button>
|
|
||||||
<button type="button" class="btn btn-success">Success</button>
|
|
||||||
<button type="button" class="btn btn-danger">Danger</button>
|
|
||||||
<button type="button" class="btn btn-warning">Warning</button>
|
|
||||||
<button type="button" class="btn btn-info">Info</button>
|
|
||||||
<button type="button" class="btn btn-light">Light</button>
|
|
||||||
<button type="button" class="btn btn-dark">Dark</button>
|
|
||||||
<button type="button" class="btn btn-link">Link</button>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<button type="button" class="btn btn-outline-primary">Outline Primary</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary">Outline Secondary</button>
|
|
||||||
<button type="button" class="btn btn-outline-success">Outline Success</button>
|
|
||||||
<button type="button" class="btn btn-outline-danger">Outline Danger</button>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<button type="button" class="btn btn-primary btn-lg">Large Button</button>
|
|
||||||
<button type="button" class="btn btn-primary">Default Button</button>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm">Small Button</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button type="button" class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Med Ikon</button>
|
|
||||||
<button type="button" class="btn btn-light border"><i class="bi bi-filter me-2"></i>Filter</button>
|
|
||||||
<button type="button" class="btn btn-light border rounded-circle p-2"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-header">Badges & Alerts</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<span class="badge bg-primary">Primary</span>
|
|
||||||
<span class="badge bg-secondary">Secondary</span>
|
|
||||||
<span class="badge bg-success">Success</span>
|
|
||||||
<span class="badge bg-danger">Danger</span>
|
|
||||||
<span class="badge bg-warning text-dark">Warning</span>
|
|
||||||
<span class="badge bg-info text-dark">Info</span>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<span class="badge rounded-pill bg-primary">Pille Form</span>
|
|
||||||
<span class="badge rounded-pill bg-success">Aktiv</span>
|
|
||||||
<span class="badge rounded-pill bg-light text-dark border">Kladde</span>
|
|
||||||
</div>
|
|
||||||
<div class="alert alert-success py-2 mb-2" role="alert">
|
|
||||||
<i class="bi bi-check-circle-fill me-2"></i>Handling gennemført!
|
|
||||||
</div>
|
|
||||||
<div class="alert alert-warning py-2 mb-0" role="alert">
|
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>Tjek venligst input.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Formularer -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="section-title">Formular Elementer</h6>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-header">Input Felter</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="exampleInputEmail1" class="form-label">Email adresse</label>
|
|
||||||
<input type="email" class="form-control" id="exampleInputEmail1" placeholder="navn@eksempel.dk">
|
|
||||||
<div class="form-text">Vi deler aldrig din email med andre.</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="exampleInputPassword1" class="form-label">Adgangskode</label>
|
|
||||||
<input type="password" class="form-control" id="exampleInputPassword1">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="disabledInput" class="form-label">Deaktiveret input</label>
|
|
||||||
<input class="form-control" id="disabledInput" type="text" placeholder="Kan ikke ændres..." disabled>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Select Menu</label>
|
|
||||||
<select class="form-select">
|
|
||||||
<option selected>Vælg en mulighed</option>
|
|
||||||
<option value="1">Mulighed 1</option>
|
|
||||||
<option value="2">Mulighed 2</option>
|
|
||||||
<option value="3">Mulighed 3</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="exampleFormControlTextarea1" class="form-label">Tekstområde</label>
|
|
||||||
<textarea class="form-control" id="exampleFormControlTextarea1" rows="3"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-header">Checkboxes, Radios & Switches</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-4">
|
|
||||||
<h6 class="small text-uppercase text-muted fw-bold">Checkboxes</h6>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" value="" id="flexCheckDefault">
|
|
||||||
<label class="form-check-label" for="flexCheckDefault">
|
|
||||||
Standard checkbox
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" value="" id="flexCheckChecked" checked>
|
|
||||||
<label class="form-check-label" for="flexCheckChecked">
|
|
||||||
Valgt checkbox
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<h6 class="small text-uppercase text-muted fw-bold">Radio Buttons</h6>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="radio" name="flexRadioDefault" id="flexRadioDefault1">
|
|
||||||
<label class="form-check-label" for="flexRadioDefault1">
|
|
||||||
Mulighed 1
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="radio" name="flexRadioDefault" id="flexRadioDefault2" checked>
|
|
||||||
<label class="form-check-label" for="flexRadioDefault2">
|
|
||||||
Mulighed 2 (Valgt)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<h6 class="small text-uppercase text-muted fw-bold">Switches</h6>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="flexSwitchCheckDefault">
|
|
||||||
<label class="form-check-label" for="flexSwitchCheckDefault">Standard switch</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="flexSwitchCheckChecked" checked>
|
|
||||||
<label class="form-check-label" for="flexSwitchCheckChecked">Aktiv switch</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h6 class="small text-uppercase text-muted fw-bold">Input Groups</h6>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<span class="input-group-text" id="basic-addon1">@</span>
|
|
||||||
<input type="text" class="form-control" placeholder="Brugernavn" aria-label="Username">
|
|
||||||
</div>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<input type="text" class="form-control" placeholder="Beløb">
|
|
||||||
<span class="input-group-text">DKK</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabeller -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="section-title">Tabeller</h6>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<span>Data Tabel</span>
|
|
||||||
<button class="btn btn-sm btn-outline-primary"><i class="bi bi-download me-1"></i>Eksportér</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover align-middle mb-0">
|
|
||||||
<thead class="bg-light">
|
|
||||||
<tr>
|
|
||||||
<th class="ps-4">Kunde</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Oprettet</th>
|
|
||||||
<th>Beløb</th>
|
|
||||||
<th class="text-end pe-4">Handling</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-4">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px; font-weight: 600;">
|
|
||||||
BN
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">BMC Networks</div>
|
|
||||||
<div class="small text-muted">CVR: 12345678</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td><span class="badge bg-success bg-opacity-10 text-success rounded-pill px-3">Aktiv</span></td>
|
|
||||||
<td>01. Dec 2023</td>
|
|
||||||
<td>4.500 DKK</td>
|
|
||||||
<td class="text-end pe-4">
|
|
||||||
<button class="btn btn-sm btn-light border"><i class="bi bi-pencil"></i></button>
|
|
||||||
<button class="btn btn-sm btn-light border text-danger"><i class="bi bi-trash"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-4">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="bg-warning bg-opacity-10 text-warning rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px; font-weight: 600;">
|
|
||||||
TC
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Tech Corp ApS</div>
|
|
||||||
<div class="small text-muted">CVR: 87654321</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td><span class="badge bg-warning bg-opacity-10 text-warning rounded-pill px-3">Afventer</span></td>
|
|
||||||
<td>28. Nov 2023</td>
|
|
||||||
<td>12.000 DKK</td>
|
|
||||||
<td class="text-end pe-4">
|
|
||||||
<button class="btn btn-sm btn-light border"><i class="bi bi-pencil"></i></button>
|
|
||||||
<button class="btn btn-sm btn-light border text-danger"><i class="bi bi-trash"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-4">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="bg-secondary bg-opacity-10 text-secondary rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px; font-weight: 600;">
|
|
||||||
LL
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Lokal Lageret</div>
|
|
||||||
<div class="small text-muted">CVR: 11223344</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td><span class="badge bg-danger bg-opacity-10 text-danger rounded-pill px-3">Opsagt</span></td>
|
|
||||||
<td>15. Okt 2023</td>
|
|
||||||
<td>2.100 DKK</td>
|
|
||||||
<td class="text-end pe-4">
|
|
||||||
<button class="btn btn-sm btn-light border"><i class="bi bi-pencil"></i></button>
|
|
||||||
<button class="btn btn-sm btn-light border text-danger"><i class="bi bi-trash"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer bg-white border-top-0 py-3">
|
|
||||||
<nav aria-label="Page navigation">
|
|
||||||
<ul class="pagination justify-content-center mb-0">
|
|
||||||
<li class="page-item disabled"><a class="page-link" href="#">Forrige</a></li>
|
|
||||||
<li class="page-item active"><a class="page-link" href="#">1</a></li>
|
|
||||||
<li class="page-item"><a class="page-link" href="#">2</a></li>
|
|
||||||
<li class="page-item"><a class="page-link" href="#">3</a></li>
|
|
||||||
<li class="page-item"><a class="page-link" href="#">Næste</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modals & Toasts -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="section-title">Modals & Overlays</h6>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Modal Eksempel</div>
|
|
||||||
<div class="card-body text-center py-5">
|
|
||||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#exampleModal">
|
|
||||||
Åbn Demo Modal
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Kort Varianter</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="card bg-primary text-white mb-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Primary Card</h5>
|
|
||||||
<p class="card-text">Kort med accent farve baggrund.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card border-primary mb-0">
|
|
||||||
<div class="card-body text-primary">
|
|
||||||
<h5 class="card-title">Border Card</h5>
|
|
||||||
<p class="card-text">Kort med farvet kant.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal -->
|
|
||||||
<div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="exampleModalLabel">Modal Titel</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>Dette er en standard modal boks. Den bruges til bekræftelser, detaljer eller formularer der ligger "ovenpå" siden.</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
|
||||||
<button type="button" class="btn btn-primary">Gem ændringer</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,321 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Nordic Topbar Customers</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-body: #f8f9fa;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
--text-primary: #2c3e50;
|
|
||||||
--text-secondary: #6c757d;
|
|
||||||
--accent: #0f4c75;
|
|
||||||
--accent-light: #eef2f5;
|
|
||||||
--border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
padding-top: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
background: var(--bg-card);
|
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.6rem 1.2rem !important;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
|
||||||
transition: transform 0.2s;
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
padding: 0.6rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: #0a3655;
|
|
||||||
border-color: #0a3655;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-search {
|
|
||||||
background: var(--bg-body);
|
|
||||||
border: 1px solid #eee;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
width: 300px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-search:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.5rem 1.2rem;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn:hover, .filter-btn.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu {
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:hover {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-lg fixed-top">
|
|
||||||
<div class="container-fluid px-4">
|
|
||||||
<a class="navbar-brand d-flex align-items-center" href="#">
|
|
||||||
<div class="bg-primary text-white rounded p-1 me-2 d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; background-color: var(--accent) !important;">
|
|
||||||
<i class="bi bi-hdd-network-fill" style="font-size: 16px;"></i>
|
|
||||||
</div>
|
|
||||||
BMC Hub
|
|
||||||
</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav mx-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="index.html"><i class="bi bi-grid me-2"></i>Dashboard</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link active dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-people me-2"></i>Kunder
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="customers.html">Oversigt</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Opret ny kunde</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-hdd-network me-2"></i>Hardware
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Alle Enheder</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Switches</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Firewalls</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Access Points</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-receipt me-2"></i>Fakturering
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Fakturaer</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Prisliste</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<button class="btn btn-light rounded-circle border-0" style="background: var(--accent-light); color: var(--accent);"><i class="bi bi-bell"></i></button>
|
|
||||||
<div class="dropdown">
|
|
||||||
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="dropdown">
|
|
||||||
<img src="https://ui-avatars.com/api/?name=CT&background=0f4c75&color=fff" class="rounded-circle me-2" width="32">
|
|
||||||
<span class="small fw-bold" style="color: var(--text-primary)">Christian</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="container-fluid px-4 py-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Kunder</h2>
|
|
||||||
<p class="text-muted mb-0">Administrer dine kunder</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" class="header-search" placeholder="Søg kunde...">
|
|
||||||
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Opret Kunde</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4 d-flex gap-2">
|
|
||||||
<button class="filter-btn active">Alle Kunder</button>
|
|
||||||
<button class="filter-btn">Aktive</button>
|
|
||||||
<button class="filter-btn">Inaktive</button>
|
|
||||||
<button class="filter-btn">VIP</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card p-4">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Virksomhed</th>
|
|
||||||
<th>Kontakt</th>
|
|
||||||
<th>CVR</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Hardware</th>
|
|
||||||
<th class="text-end"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded bg-light d-flex align-items-center justify-content-center me-3 fw-bold" style="width: 40px; height: 40px; color: var(--accent);">AG</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Advokatgruppen A/S</div>
|
|
||||||
<div class="small text-muted">København K</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-medium">Jens Jensen</div>
|
|
||||||
<div class="small text-muted">jens@advokat.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">12345678</td>
|
|
||||||
<td><span class="badge bg-success bg-opacity-10 text-success">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark border fw-normal">Firewall</span>
|
|
||||||
<span class="badge bg-light text-dark border fw-normal">Switch</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-light"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded bg-light d-flex align-items-center justify-content-center me-3 fw-bold" style="width: 40px; height: 40px; color: var(--accent);">BB</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Byg & Bo ApS</div>
|
|
||||||
<div class="small text-muted">Aarhus C</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-medium">Mette Hansen</div>
|
|
||||||
<div class="small text-muted">mh@bygbo.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">87654321</td>
|
|
||||||
<td><span class="badge bg-success bg-opacity-10 text-success">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark border fw-normal">Router</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-light"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded bg-light d-flex align-items-center justify-content-center me-3 fw-bold" style="width: 40px; height: 40px; color: var(--accent);">CM</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Cafe Møller</div>
|
|
||||||
<div class="small text-muted">Odense M</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-medium">Peter Møller</div>
|
|
||||||
<div class="small text-muted">pm@cafe.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">11223344</td>
|
|
||||||
<td><span class="badge bg-warning bg-opacity-10 text-warning">Afventer</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="text-muted">-</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-light"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-center mt-4">
|
|
||||||
<nav>
|
|
||||||
<ul class="pagination">
|
|
||||||
<li class="page-item disabled"><a class="page-link border-0" href="#">Forrige</a></li>
|
|
||||||
<li class="page-item active"><a class="page-link border-0 rounded-circle mx-1" style="background-color: var(--accent);" href="#">1</a></li>
|
|
||||||
<li class="page-item"><a class="page-link border-0 text-dark" href="#">2</a></li>
|
|
||||||
<li class="page-item"><a class="page-link border-0 text-dark" href="#">3</a></li>
|
|
||||||
<li class="page-item"><a class="page-link border-0 text-dark" href="#">Næste</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,276 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Opret Kunde</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-body: #f8f9fa;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
--text-primary: #2c3e50;
|
|
||||||
--text-secondary: #6c757d;
|
|
||||||
--accent: #0f4c75;
|
|
||||||
--accent-light: #eef2f5;
|
|
||||||
--border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
padding-top: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
background: var(--bg-card);
|
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.6rem 1.2rem !important;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
|
||||||
transition: transform 0.2s;
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
padding: 0.6rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: #0a3655;
|
|
||||||
border-color: #0a3655;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control, .form-select {
|
|
||||||
padding: 0.7rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus, .form-select:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu {
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:hover {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-lg fixed-top">
|
|
||||||
<div class="container-fluid px-4">
|
|
||||||
<a class="navbar-brand d-flex align-items-center" href="#">
|
|
||||||
<div class="bg-primary text-white rounded p-1 me-2 d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; background-color: var(--accent) !important;">
|
|
||||||
<i class="bi bi-hdd-network-fill" style="font-size: 16px;"></i>
|
|
||||||
</div>
|
|
||||||
BMC Hub
|
|
||||||
</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav mx-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="index.html"><i class="bi bi-grid me-2"></i>Dashboard</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link active dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-people me-2"></i>Kunder
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="customers.html">Oversigt</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Opret ny kunde</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-hdd-network me-2"></i>Hardware
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Alle Enheder</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Switches</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Firewalls</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Access Points</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-receipt me-2"></i>Fakturering
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Fakturaer</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Prisliste</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<button class="btn btn-light rounded-circle border-0" style="background: var(--accent-light); color: var(--accent);"><i class="bi bi-bell"></i></button>
|
|
||||||
<div class="dropdown">
|
|
||||||
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="dropdown">
|
|
||||||
<img src="https://ui-avatars.com/api/?name=CT&background=0f4c75&color=fff" class="rounded-circle me-2" width="32">
|
|
||||||
<span class="small fw-bold" style="color: var(--text-primary)">Christian</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="container-fluid px-4 py-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Opret Ny Kunde</h2>
|
|
||||||
<p class="text-muted mb-0">Udfyld oplysningerne herunder</p>
|
|
||||||
</div>
|
|
||||||
<a href="customers.html" class="btn btn-light"><i class="bi bi-arrow-left me-2"></i>Tilbage</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card p-4">
|
|
||||||
<form>
|
|
||||||
<h5 class="fw-bold mb-4 text-primary" style="color: var(--accent) !important;">Virksomhedsoplysninger</h5>
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<label class="form-label">Virksomhedsnavn</label>
|
|
||||||
<input type="text" class="form-control" placeholder="F.eks. BMC Networks ApS">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">CVR Nummer</label>
|
|
||||||
<input type="text" class="form-control" placeholder="12345678">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Branche</label>
|
|
||||||
<select class="form-select">
|
|
||||||
<option selected>Vælg branche...</option>
|
|
||||||
<option>IT & Teknologi</option>
|
|
||||||
<option>Håndværk</option>
|
|
||||||
<option>Detailhandel</option>
|
|
||||||
<option>Rådgivning</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h5 class="fw-bold mb-4 text-primary" style="color: var(--accent) !important;">Kontaktperson</h5>
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Navn</label>
|
|
||||||
<input type="text" class="form-control" placeholder="Kontaktpersonens navn">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Email</label>
|
|
||||||
<input type="email" class="form-control" placeholder="mail@virksomhed.dk">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Telefon</label>
|
|
||||||
<input type="tel" class="form-control" placeholder="+45 12 34 56 78">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Stilling</label>
|
|
||||||
<input type="text" class="form-control" placeholder="F.eks. Direktør">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h5 class="fw-bold mb-4 text-primary" style="color: var(--accent) !important;">Abonnement</h5>
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="card bg-light border-0 p-3">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="radio" name="plan" id="plan1" checked>
|
|
||||||
<label class="form-check-label fw-bold" for="plan1">
|
|
||||||
Standard Support
|
|
||||||
</label>
|
|
||||||
<div class="small text-muted">Inkluderer telefon og email support i åbningstiden.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="card bg-light border-0 p-3">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="radio" name="plan" id="plan2">
|
|
||||||
<label class="form-check-label fw-bold" for="plan2">
|
|
||||||
Premium Support (24/7)
|
|
||||||
</label>
|
|
||||||
<div class="small text-muted">Døgnet rundt support og overvågning.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="form-label">Noter</label>
|
|
||||||
<textarea class="form-control" rows="3" placeholder="Eventuelle bemærkninger..."></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="my-4">
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-end gap-2">
|
|
||||||
<button type="button" class="btn btn-light">Annuller</button>
|
|
||||||
<button type="submit" class="btn btn-primary px-4">Opret Kunde</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,329 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Nordic Topbar</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-body: #f8f9fa;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
--text-primary: #2c3e50;
|
|
||||||
--text-secondary: #6c757d;
|
|
||||||
--accent: #0f4c75;
|
|
||||||
--accent-light: #eef2f5;
|
|
||||||
--border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
padding-top: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
background: var(--bg-card);
|
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.6rem 1.2rem !important;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
|
||||||
transition: transform 0.2s;
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card h3 {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card p {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
padding: 0.6rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: #0a3655;
|
|
||||||
border-color: #0a3655;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-search {
|
|
||||||
background: var(--bg-body);
|
|
||||||
border: 1px solid #eee;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
width: 300px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-search:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu {
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:hover {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-lg fixed-top">
|
|
||||||
<div class="container-fluid px-4">
|
|
||||||
<a class="navbar-brand d-flex align-items-center" href="#">
|
|
||||||
<div class="bg-primary text-white rounded p-1 me-2 d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; background-color: var(--accent) !important;">
|
|
||||||
<i class="bi bi-hdd-network-fill" style="font-size: 16px;"></i>
|
|
||||||
</div>
|
|
||||||
BMC Hub
|
|
||||||
</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav mx-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" href="index.html"><i class="bi bi-grid me-2"></i>Dashboard</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-people me-2"></i>Kunder
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="customers.html">Oversigt</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Opret ny kunde</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-hdd-network me-2"></i>Hardware
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Alle Enheder</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Switches</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Firewalls</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Access Points</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-receipt me-2"></i>Fakturering
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Fakturaer</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Prisliste</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<button class="btn btn-light rounded-circle border-0" style="background: var(--accent-light); color: var(--accent);"><i class="bi bi-bell"></i></button>
|
|
||||||
<div class="dropdown">
|
|
||||||
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="dropdown">
|
|
||||||
<img src="https://ui-avatars.com/api/?name=CT&background=0f4c75&color=fff" class="rounded-circle me-2" width="32">
|
|
||||||
<span class="small fw-bold" style="color: var(--text-primary)">Christian</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="container-fluid px-4 py-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Dashboard</h2>
|
|
||||||
<p class="text-muted mb-0">Velkommen tilbage, Christian</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" class="header-search" placeholder="Søg...">
|
|
||||||
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Ny Opgave</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4 mb-5">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card stat-card p-4 h-100">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<p>Aktive Kunder</p>
|
|
||||||
<i class="bi bi-people text-primary" style="color: var(--accent) !important;"></i>
|
|
||||||
</div>
|
|
||||||
<h3>124</h3>
|
|
||||||
<small class="text-success"><i class="bi bi-arrow-up-short"></i> 12% denne måned</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card stat-card p-4 h-100">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<p>Hardware</p>
|
|
||||||
<i class="bi bi-hdd text-primary" style="color: var(--accent) !important;"></i>
|
|
||||||
</div>
|
|
||||||
<h3>856</h3>
|
|
||||||
<small class="text-muted">Enheder online</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card stat-card p-4 h-100">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<p>Support</p>
|
|
||||||
<i class="bi bi-ticket text-primary" style="color: var(--accent) !important;"></i>
|
|
||||||
</div>
|
|
||||||
<h3>12</h3>
|
|
||||||
<small class="text-warning">3 kræver handling</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card stat-card p-4 h-100">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<p>Omsætning</p>
|
|
||||||
<i class="bi bi-currency-dollar text-primary" style="color: var(--accent) !important;"></i>
|
|
||||||
</div>
|
|
||||||
<h3>450k</h3>
|
|
||||||
<small class="text-success">Over budget</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card p-4">
|
|
||||||
<h5 class="fw-bold mb-4">Seneste Aktiviteter</h5>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Kunde</th>
|
|
||||||
<th>Handling</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th class="text-end">Tid</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="fw-bold">Advokatgruppen A/S</td>
|
|
||||||
<td>Firewall konfiguration</td>
|
|
||||||
<td><span class="badge bg-success bg-opacity-10 text-success">Fuldført</span></td>
|
|
||||||
<td class="text-end text-muted">10:23</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="fw-bold">Byg & Bo ApS</td>
|
|
||||||
<td>Licens fornyelse</td>
|
|
||||||
<td><span class="badge bg-warning bg-opacity-10 text-warning">Afventer</span></td>
|
|
||||||
<td class="text-end text-muted">I går</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="fw-bold">Cafe Møller</td>
|
|
||||||
<td>Netværksnedbrud</td>
|
|
||||||
<td><span class="badge bg-danger bg-opacity-10 text-danger">Kritisk</span></td>
|
|
||||||
<td class="text-end text-muted">I går</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card p-4 h-100">
|
|
||||||
<h5 class="fw-bold mb-4">System Status</h5>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<span class="small fw-bold text-muted">CPU LOAD</span>
|
|
||||||
<span class="small fw-bold">24%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 8px; background-color: var(--accent-light);">
|
|
||||||
<div class="progress-bar" style="width: 24%; background-color: var(--accent);"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<span class="small fw-bold text-muted">MEMORY</span>
|
|
||||||
<span class="small fw-bold">56%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 8px; background-color: var(--accent-light);">
|
|
||||||
<div class="progress-bar" style="width: 56%; background-color: var(--accent);"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto p-3 rounded" style="background-color: var(--accent-light);">
|
|
||||||
<div class="d-flex">
|
|
||||||
<i class="bi bi-check-circle-fill text-success me-2"></i>
|
|
||||||
<small class="fw-bold" style="color: var(--accent)">Alle systemer kører optimalt.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Login</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-body: #f8f9fa;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
--text-primary: #2c3e50;
|
|
||||||
--text-secondary: #6c757d;
|
|
||||||
--accent: #0f4c75;
|
|
||||||
--accent-light: #eef2f5;
|
|
||||||
--border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
box-shadow: 0 10px 40px rgba(0,0,0,0.08);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
padding: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-logo {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
background-color: var(--accent);
|
|
||||||
color: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 24px;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
background-color: #fcfcfc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
padding: 0.8rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: #0a3655;
|
|
||||||
border-color: #0a3655;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="login-card">
|
|
||||||
<div class="d-flex flex-column align-items-center text-center mb-4">
|
|
||||||
<div class="brand-logo">
|
|
||||||
<i class="bi bi-hdd-network-fill"></i>
|
|
||||||
</div>
|
|
||||||
<h4 class="fw-bold mb-1">Velkommen tilbage</h4>
|
|
||||||
<p class="text-muted small">Log ind for at få adgang til BMC Hub</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Email adresse</label>
|
|
||||||
<input type="email" class="form-control" placeholder="navn@bmcnetworks.dk" value="christian@bmcnetworks.dk">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Adgangskode</label>
|
|
||||||
<input type="password" class="form-control" value="password123">
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="remember">
|
|
||||||
<label class="form-check-label small text-muted" for="remember">Husk mig</label>
|
|
||||||
</div>
|
|
||||||
<a href="#" class="small text-decoration-none" style="color: var(--accent)">Glemt kode?</a>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary" onclick="window.location.href='index.html'; return false;">Log Ind</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,262 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Indstillinger</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-body: #f8f9fa;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
--text-primary: #2c3e50;
|
|
||||||
--text-secondary: #6c757d;
|
|
||||||
--accent: #0f4c75;
|
|
||||||
--accent-light: #eef2f5;
|
|
||||||
--border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
padding-top: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
background: var(--bg-card);
|
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.6rem 1.2rem !important;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
|
||||||
transition: transform 0.2s;
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
padding: 0.6rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: #0a3655;
|
|
||||||
border-color: #0a3655;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-nav .nav-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.8rem 1rem !important;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-nav .nav-link:hover {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-nav .nav-link.active {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control, .form-select {
|
|
||||||
padding: 0.7rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus, .form-select:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu {
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:hover {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-lg fixed-top">
|
|
||||||
<div class="container-fluid px-4">
|
|
||||||
<a class="navbar-brand d-flex align-items-center" href="#">
|
|
||||||
<div class="bg-primary text-white rounded p-1 me-2 d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; background-color: var(--accent) !important;">
|
|
||||||
<i class="bi bi-hdd-network-fill" style="font-size: 16px;"></i>
|
|
||||||
</div>
|
|
||||||
BMC Hub
|
|
||||||
</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav mx-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="index.html"><i class="bi bi-grid me-2"></i>Dashboard</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-people me-2"></i>Kunder
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="customers.html">Oversigt</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Opret ny kunde</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-hdd-network me-2"></i>Hardware
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Alle Enheder</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Switches</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Firewalls</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Access Points</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-receipt me-2"></i>Fakturering
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu mt-2">
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Fakturaer</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="#">Prisliste</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<button class="btn btn-light rounded-circle border-0" style="background: var(--accent-light); color: var(--accent);"><i class="bi bi-bell"></i></button>
|
|
||||||
<div class="dropdown">
|
|
||||||
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="dropdown">
|
|
||||||
<img src="https://ui-avatars.com/api/?name=CT&background=0f4c75&color=fff" class="rounded-circle me-2" width="32">
|
|
||||||
<span class="small fw-bold" style="color: var(--text-primary)">Christian</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="container-fluid px-4 py-4">
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-lg-3">
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="nav flex-column settings-nav">
|
|
||||||
<a class="nav-link active" href="#"><i class="bi bi-person me-3"></i>Min Profil</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-shield-lock me-3"></i>Sikkerhed</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-bell me-3"></i>Notifikationer</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-palette me-3"></i>Udseende</a>
|
|
||||||
<hr class="my-2">
|
|
||||||
<a class="nav-link text-danger" href="login.html"><i class="bi bi-box-arrow-right me-3"></i>Log ud</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-9">
|
|
||||||
<div class="card p-4 mb-4">
|
|
||||||
<h4 class="fw-bold mb-4">Min Profil</h4>
|
|
||||||
|
|
||||||
<div class="d-flex align-items-center mb-4">
|
|
||||||
<img src="https://ui-avatars.com/api/?name=CT&background=0f4c75&color=fff&size=128" class="rounded-circle me-4" width="80">
|
|
||||||
<div>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm me-2">Skift Billede</button>
|
|
||||||
<button class="btn btn-link btn-sm text-danger text-decoration-none">Fjern</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form>
|
|
||||||
<div class="row g-3 mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Fornavn</label>
|
|
||||||
<input type="text" class="form-control" value="Christian">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Efternavn</label>
|
|
||||||
<input type="text" class="form-control" value="Thomas">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Email</label>
|
|
||||||
<input type="email" class="form-control" value="christian@bmcnetworks.dk">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Rolle</label>
|
|
||||||
<input type="text" class="form-control" value="Administrator" disabled>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-end mt-4">
|
|
||||||
<button type="button" class="btn btn-light me-2">Annuller</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Gem Ændringer</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card p-4">
|
|
||||||
<h4 class="fw-bold mb-4">Præferencer</h4>
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="emailNotif" checked>
|
|
||||||
<label class="form-check-label" for="emailNotif">Modtag email notifikationer</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="darkMode">
|
|
||||||
<label class="form-check-label" for="darkMode">Dark Mode (Beta)</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
# BMC Hub - Design System & UI Plan
|
|
||||||
|
|
||||||
## 1. Design Filosofi: "Nordic Top"
|
|
||||||
Dette designdokument beskriver UI/UX-strategien for BMC Hub, baseret på templaten **10_nordic_top**. Designet er forankret i skandinavisk minimalisme med fokus på funktionalitet, luft og overskuelighed.
|
|
||||||
|
|
||||||
**Nøgleprincipper:**
|
|
||||||
* **Fuld Bredde (Fluid Layout):** Udnytter hele skærmens bredde for maksimalt overblik, ideelt til store datatabeller og dashboards.
|
|
||||||
* **Top-Navigation:** En fastgjort topbar frigiver plads i siderne og giver en velkendt, hierarkisk navigationsstruktur med dropdowns.
|
|
||||||
* **Kort-baseret UI:** Indhold grupperes i "cards" med bløde skygger og rundede hjørner for at skabe visuelt hierarki.
|
|
||||||
* **Accent-farver:** En dyb blå (`#0f4c75`) bruges konsekvent til handlinger og branding, suppleret af lyse baggrunde.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Visuel Identitet
|
|
||||||
|
|
||||||
### Farvepalette
|
|
||||||
Designet benytter en stram palette defineret via CSS variabler for nem vedligeholdelse.
|
|
||||||
|
|
||||||
| Variabel | Farvekode | Anvendelse |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `--accent` | `#0f4c75` (Dyb Blå) | Primære knapper, aktiv navigation, branding, ikoner |
|
|
||||||
| `--accent-light` | `#eef2f5` (Lys Blå/Grå) | Hover-effekter, baggrund for ikoner, aktive menupunkter |
|
|
||||||
| `--text-primary` | `#2c3e50` (Mørkegrå) | Overskrifter, primær tekst |
|
|
||||||
| `--text-secondary` | `#6c757d` (Mellemgrå) | Brødtekst, labels, meta-data |
|
|
||||||
| `--bg-body` | `#f8f9fa` (Off-white) | Sidens baggrundsfarve |
|
|
||||||
| `--bg-card` | `#ffffff` (Hvid) | Kort, navbar, modaler |
|
|
||||||
|
|
||||||
### Typografi
|
|
||||||
* **Font:** 'Inter', system-ui, sans-serif.
|
|
||||||
* **Vægtning:**
|
|
||||||
* **Bold (700):** Overskrifter, KPI-tal.
|
|
||||||
* **Medium (500):** Labels, menupunkter, knaptekst.
|
|
||||||
* **Regular (400):** Brødtekst.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Komponentbibliotek
|
|
||||||
|
|
||||||
### Navigation (Topbar)
|
|
||||||
* **Struktur:** Logo venstre, Menu center, Profil/Notifikationer højre.
|
|
||||||
* **Dropdowns:** Bruges til underkategorier (f.eks. Kunder -> Oversigt, Opret, Rapporter).
|
|
||||||
* **Aktiv Tilstand:** Markeres med `--accent-light` baggrund og `--accent` tekstfarve.
|
|
||||||
|
|
||||||
### Knapper (Buttons)
|
|
||||||
* **Primær:** Fuld `--accent` baggrund. Bruges til hovedhandlinger (f.eks. "Opret Kunde", "Gem").
|
|
||||||
* **Sekundær/Filter:** Hvid baggrund med border. Bliver farvet ved aktiv/hover.
|
|
||||||
* **Ikon-knapper:** Runde knapper til hurtige handlinger (f.eks. notifikationer, rediger).
|
|
||||||
|
|
||||||
### Tabeller (Data Lists)
|
|
||||||
* **Design:** Clean look uden lodrette streger.
|
|
||||||
* **Features:**
|
|
||||||
* Avatar/Initialer for virksomheder.
|
|
||||||
* Status badges (pille-formede).
|
|
||||||
* Handlingsmenu (tre prikker) yderst til højre.
|
|
||||||
* Hover-effekt på rækker for læsbarhed.
|
|
||||||
|
|
||||||
### Formularer (Inputs)
|
|
||||||
* **Styling:** Store, luftige inputfelter med blød border.
|
|
||||||
* **Fokus:** Tydelig `--accent` border og skygge ved fokus.
|
|
||||||
* **Gruppering:** Logisk opdeling med overskrifter (f.eks. "Virksomhedsoplysninger", "Kontaktperson").
|
|
||||||
* **Kort-valg:** Radio buttons designet som klikbare kort (se Abonnement-valg i `form.html`).
|
|
||||||
|
|
||||||
### Status Indikatorer (Badges)
|
|
||||||
Bruges til at vise tilstande hurtigt i tabeller.
|
|
||||||
* 🟢 **Success:** Aktiv, Fuldført (Grøn baggrund/tekst).
|
|
||||||
* 🟡 **Warning:** Afventer, Pause (Gul/Orange baggrund/tekst).
|
|
||||||
* 🔴 **Danger:** Kritisk, Opsagt (Rød baggrund/tekst).
|
|
||||||
* ⚪ **Neutral:** Hardware typer (Grå baggrund/tekst).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Side-Skabeloner (Templates)
|
|
||||||
|
|
||||||
Systemet består af 5 kerneskabeloner der dækker de fleste behov:
|
|
||||||
|
|
||||||
### 1. Dashboard (`index.html`)
|
|
||||||
* **Formål:** Give hurtigt overblik over forretningens tilstand.
|
|
||||||
* **Indhold:**
|
|
||||||
* 4 KPI-kort i toppen (Kunder, Hardware, Support, Omsætning).
|
|
||||||
* Grafisk visning (placeholder) eller lister.
|
|
||||||
* "Seneste Aktiviteter" tabel.
|
|
||||||
* System Status panel (Server load, memory).
|
|
||||||
|
|
||||||
### 2. Listevisning (`customers.html`)
|
|
||||||
* **Formål:** Administration af store datamængder.
|
|
||||||
* **Indhold:**
|
|
||||||
* Søgefelt og "Opret" knap i header.
|
|
||||||
* Filter-bar (Alle, Aktive, Inaktive, VIP).
|
|
||||||
* Datatabel med rig information.
|
|
||||||
* Paginering i bunden.
|
|
||||||
|
|
||||||
### 3. Formular / Opret (`form.html`)
|
|
||||||
* **Formål:** Indtastning af data (CRUD operationer).
|
|
||||||
* **Indhold:**
|
|
||||||
* "Tilbage" knap for nem navigation.
|
|
||||||
* Opdelt formular i sektioner.
|
|
||||||
* Forskellige input typer: Text, Email, Tel, Select, Radio Cards, Textarea.
|
|
||||||
|
|
||||||
### 4. Indstillinger (`settings.html`)
|
|
||||||
* **Formål:** Brugerprofil og systemkonfiguration.
|
|
||||||
* **Indhold:**
|
|
||||||
* 2-kolonne layout.
|
|
||||||
* Venstre: Vertikal navigationsmenu.
|
|
||||||
* Højre: Indholdspaneler (Profilbillede, Stamdata, Toggles for notifikationer/Dark mode).
|
|
||||||
|
|
||||||
### 5. Login (`login.html`)
|
|
||||||
* **Formål:** Adgangskontrol.
|
|
||||||
* **Indhold:**
|
|
||||||
* Centreret kort på fuld skærm.
|
|
||||||
* Logo og velkomsttekst.
|
|
||||||
* Email/Password felter.
|
|
||||||
* "Husk mig" og "Glemt kode" funktioner.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Brugerrejse & Navigation
|
|
||||||
|
|
||||||
### Flow Eksempel: Opret ny kunde
|
|
||||||
1. **Login:** Bruger logger ind via `login.html`.
|
|
||||||
2. **Dashboard:** Lander på `index.html` og ser overblik.
|
|
||||||
3. **Navigation:** Klikker på "Kunder" i topmenuen -> Vælger "Oversigt" (eller direkte "Opret ny kunde").
|
|
||||||
4. **Liste:** Ser listen på `customers.html`, søger evt. efter eksisterende.
|
|
||||||
5. **Handling:** Klikker "Opret Kunde" knappen.
|
|
||||||
6. **Formular:** Udfylder data på `form.html`.
|
|
||||||
* Vælger branche via dropdown.
|
|
||||||
* Vælger abonnementspakke via visuelle kort.
|
|
||||||
7. **Afslutning:** Klikker "Opret Kunde" -> Sendes tilbage til `customers.html` (eller detaljevisning).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Teknisk Implementation
|
|
||||||
|
|
||||||
* **Framework:** Bootstrap 5.3.2 (CSS/JS).
|
|
||||||
* **Ikoner:** Bootstrap Icons (CDN).
|
|
||||||
* **Layout:** `container-fluid` for fuld bredde, `row` og `col-*` grid system.
|
|
||||||
* **Responsivitet:**
|
|
||||||
* Navbar kollapser til "burger menu" på mobil.
|
|
||||||
* Tabeller bliver scrollbare horisontalt (`table-responsive`).
|
|
||||||
* Grid system tilpasser sig fra 4 kolonner (desktop) til 1 kolonne (mobil).
|
|
||||||
60
main.py
60
main.py
@ -12,26 +12,12 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import init_db
|
from app.core.database import init_db
|
||||||
|
from app.routers import (
|
||||||
# Import Feature Routers
|
customers,
|
||||||
from app.auth.backend import router as auth_api
|
hardware,
|
||||||
from app.auth.backend import views as auth_views
|
billing,
|
||||||
from app.customers.backend import router as customers_api
|
system,
|
||||||
from app.customers.backend import views as customers_views
|
)
|
||||||
from app.contacts.backend import router as contacts_api
|
|
||||||
from app.contacts.backend import views as contacts_views
|
|
||||||
from app.vendors.backend import router as vendors_api
|
|
||||||
from app.vendors.backend import views as vendors_views
|
|
||||||
from app.settings.backend import router as settings_api
|
|
||||||
from app.settings.backend import views as settings_views
|
|
||||||
from app.hardware.backend import router as hardware_api
|
|
||||||
from app.billing.backend import router as billing_api
|
|
||||||
from app.billing.frontend import views as billing_views
|
|
||||||
from app.system.backend import router as system_api
|
|
||||||
from app.dashboard.backend import views as dashboard_views
|
|
||||||
from app.dashboard.backend import router as dashboard_api
|
|
||||||
from app.devportal.backend import router as devportal_api
|
|
||||||
from app.devportal.backend import views as devportal_views
|
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -78,11 +64,6 @@ app = FastAPI(
|
|||||||
openapi_url="/api/openapi.json"
|
openapi_url="/api/openapi.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def root():
|
|
||||||
"""Redirect root to dashboard"""
|
|
||||||
return RedirectResponse(url="/dashboard")
|
|
||||||
|
|
||||||
# CORS middleware
|
# CORS middleware
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@ -93,30 +74,19 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(auth_api.router, prefix="/api/v1/auth", tags=["Authentication"])
|
app.include_router(customers.router, prefix="/api/v1", tags=["Customers"])
|
||||||
app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"])
|
app.include_router(hardware.router, prefix="/api/v1", tags=["Hardware"])
|
||||||
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
|
app.include_router(billing.router, prefix="/api/v1", tags=["Billing"])
|
||||||
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
|
app.include_router(system.router, prefix="/api/v1", tags=["System"])
|
||||||
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
|
|
||||||
app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"])
|
|
||||||
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
|
|
||||||
app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
|
|
||||||
app.include_router(dashboard_api.router, prefix="/api/v1/dashboard", tags=["Dashboard"])
|
|
||||||
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["DEV Portal"])
|
|
||||||
|
|
||||||
# Frontend Routers
|
|
||||||
app.include_router(auth_views.router, tags=["Frontend"])
|
|
||||||
app.include_router(dashboard_views.router, tags=["Frontend"])
|
|
||||||
app.include_router(customers_views.router, tags=["Frontend"])
|
|
||||||
app.include_router(contacts_views.router, tags=["Frontend"])
|
|
||||||
app.include_router(vendors_views.router, tags=["Frontend"])
|
|
||||||
app.include_router(billing_views.router, tags=["Frontend"])
|
|
||||||
app.include_router(settings_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")
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Redirect to dashboard"""
|
||||||
|
return RedirectResponse(url="/static/index.html")
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check endpoint"""
|
"""Health check endpoint"""
|
||||||
|
|||||||
@ -1,215 +0,0 @@
|
|||||||
-- Migration 002: Authentication & Authorization System
|
|
||||||
-- Based on OmniSync auth implementation
|
|
||||||
|
|
||||||
-- Users table
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
user_id SERIAL PRIMARY KEY,
|
|
||||||
username VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
|
||||||
full_name VARCHAR(255),
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
is_superadmin BOOLEAN DEFAULT FALSE,
|
|
||||||
failed_login_attempts INTEGER DEFAULT 0,
|
|
||||||
locked_until TIMESTAMP,
|
|
||||||
last_login_at TIMESTAMP,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Groups table
|
|
||||||
CREATE TABLE IF NOT EXISTS groups (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Permissions table
|
|
||||||
CREATE TABLE IF NOT EXISTS permissions (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
code VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
category VARCHAR(50),
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- User-Group relationship
|
|
||||||
CREATE TABLE IF NOT EXISTS user_groups (
|
|
||||||
user_id INTEGER REFERENCES users(user_id) ON DELETE CASCADE,
|
|
||||||
group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
|
||||||
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (user_id, group_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Group-Permission relationship
|
|
||||||
CREATE TABLE IF NOT EXISTS group_permissions (
|
|
||||||
group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
|
||||||
permission_id INTEGER REFERENCES permissions(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (group_id, permission_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Sessions table (for JWT token revocation)
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
user_id INTEGER REFERENCES users(user_id) ON DELETE CASCADE,
|
|
||||||
token_jti VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
expires_at TIMESTAMP NOT NULL,
|
|
||||||
revoked BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Audit log table
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
|
||||||
username VARCHAR(100),
|
|
||||||
action VARCHAR(100) NOT NULL,
|
|
||||||
resource_type VARCHAR(100),
|
|
||||||
resource_id INTEGER,
|
|
||||||
resource_name VARCHAR(255),
|
|
||||||
before_value TEXT,
|
|
||||||
after_value TEXT,
|
|
||||||
success BOOLEAN DEFAULT TRUE,
|
|
||||||
error_message TEXT,
|
|
||||||
ip_address VARCHAR(45),
|
|
||||||
user_agent TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Create indexes
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_jti ON sessions(token_jti);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);
|
|
||||||
|
|
||||||
-- Insert default permissions
|
|
||||||
INSERT INTO permissions (code, description, category) VALUES
|
|
||||||
-- Customers
|
|
||||||
('customers.view', 'View customers', 'customers'),
|
|
||||||
('customers.edit', 'Edit customers', 'customers'),
|
|
||||||
('customers.create', 'Create customers', 'customers'),
|
|
||||||
('customers.delete', 'Delete customers', 'customers'),
|
|
||||||
|
|
||||||
-- Hardware
|
|
||||||
('hardware.view', 'View hardware', 'hardware'),
|
|
||||||
('hardware.edit', 'Edit hardware', 'hardware'),
|
|
||||||
('hardware.create', 'Create hardware', 'hardware'),
|
|
||||||
('hardware.delete', 'Delete hardware', 'hardware'),
|
|
||||||
('hardware.assign', 'Assign hardware to customers', 'hardware'),
|
|
||||||
|
|
||||||
-- Billing
|
|
||||||
('billing.view', 'View billing information', 'billing'),
|
|
||||||
('billing.edit', 'Edit billing information', 'billing'),
|
|
||||||
('billing.approve', 'Approve billing items', 'billing'),
|
|
||||||
|
|
||||||
-- System
|
|
||||||
('system.view', 'View system settings', 'system'),
|
|
||||||
('system.edit', 'Edit system settings', 'system'),
|
|
||||||
|
|
||||||
-- Reports
|
|
||||||
('reports.view', 'View reports', 'reports'),
|
|
||||||
('reports.export', 'Export reports', 'reports'),
|
|
||||||
|
|
||||||
-- Audit
|
|
||||||
('audit.view', 'View audit logs', 'audit'),
|
|
||||||
|
|
||||||
-- Admin
|
|
||||||
('users.manage', 'Manage users and groups', 'admin'),
|
|
||||||
('permissions.manage', 'Manage permissions', 'admin'),
|
|
||||||
('system.admin', 'Full system administration', 'admin')
|
|
||||||
ON CONFLICT (code) DO NOTHING;
|
|
||||||
|
|
||||||
-- Insert default groups
|
|
||||||
INSERT INTO groups (name, description) VALUES
|
|
||||||
('Administrators', 'Full system access'),
|
|
||||||
('Managers', 'Can manage customers and billing'),
|
|
||||||
('Technicians', 'Can manage hardware and assignments'),
|
|
||||||
('Viewers', 'Read-only access')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- Assign permissions to Administrators group (all permissions)
|
|
||||||
INSERT INTO group_permissions (group_id, permission_id)
|
|
||||||
SELECT g.id, p.id
|
|
||||||
FROM groups g
|
|
||||||
CROSS JOIN permissions p
|
|
||||||
WHERE g.name = 'Administrators'
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Assign permissions to Managers group
|
|
||||||
INSERT INTO group_permissions (group_id, permission_id)
|
|
||||||
SELECT g.id, p.id
|
|
||||||
FROM groups g
|
|
||||||
CROSS JOIN permissions p
|
|
||||||
WHERE g.name = 'Managers' AND p.code IN (
|
|
||||||
'customers.view', 'customers.edit', 'customers.create',
|
|
||||||
'hardware.view',
|
|
||||||
'billing.view', 'billing.edit', 'billing.approve',
|
|
||||||
'reports.view', 'reports.export'
|
|
||||||
)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Assign permissions to Technicians group
|
|
||||||
INSERT INTO group_permissions (group_id, permission_id)
|
|
||||||
SELECT g.id, p.id
|
|
||||||
FROM groups g
|
|
||||||
CROSS JOIN permissions p
|
|
||||||
WHERE g.name = 'Technicians' AND p.code IN (
|
|
||||||
'customers.view',
|
|
||||||
'hardware.view', 'hardware.edit', 'hardware.create', 'hardware.assign',
|
|
||||||
'reports.view'
|
|
||||||
)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Assign permissions to Viewers group
|
|
||||||
INSERT INTO group_permissions (group_id, permission_id)
|
|
||||||
SELECT g.id, p.id
|
|
||||||
FROM groups g
|
|
||||||
CROSS JOIN permissions p
|
|
||||||
WHERE g.name = 'Viewers' AND p.code IN (
|
|
||||||
'customers.view',
|
|
||||||
'hardware.view',
|
|
||||||
'billing.view',
|
|
||||||
'reports.view'
|
|
||||||
)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Create default admin user (password: admin123, hashed with SHA256)
|
|
||||||
-- SHA256 hash of 'admin123'
|
|
||||||
INSERT INTO users (username, email, password_hash, full_name, is_superadmin, is_active)
|
|
||||||
VALUES (
|
|
||||||
'admin',
|
|
||||||
'admin@bmcnetworks.dk',
|
|
||||||
'240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9',
|
|
||||||
'System Administrator',
|
|
||||||
TRUE,
|
|
||||||
TRUE
|
|
||||||
)
|
|
||||||
ON CONFLICT (username) DO NOTHING;
|
|
||||||
|
|
||||||
-- Assign admin user to Administrators group
|
|
||||||
INSERT INTO user_groups (user_id, group_id)
|
|
||||||
SELECT u.user_id, g.id
|
|
||||||
FROM users u
|
|
||||||
CROSS JOIN groups g
|
|
||||||
WHERE u.username = 'admin' AND g.name = 'Administrators'
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Update timestamp trigger function
|
|
||||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ language 'plpgsql';
|
|
||||||
|
|
||||||
-- Add trigger to users table
|
|
||||||
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
|
||||||
CREATE TRIGGER update_users_updated_at
|
|
||||||
BEFORE UPDATE ON users
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_updated_at_column();
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
-- Migration 003: Udvid customers tabel med felter fra OmniSync
|
|
||||||
-- Dato: 6. december 2025
|
|
||||||
|
|
||||||
-- Tilføj nye kolonner til customers tabel
|
|
||||||
ALTER TABLE customers
|
|
||||||
ADD COLUMN IF NOT EXISTS cvr_number VARCHAR(20) UNIQUE,
|
|
||||||
ADD COLUMN IF NOT EXISTS email_domain VARCHAR(255),
|
|
||||||
ADD COLUMN IF NOT EXISTS city VARCHAR(100),
|
|
||||||
ADD COLUMN IF NOT EXISTS postal_code VARCHAR(10),
|
|
||||||
ADD COLUMN IF NOT EXISTS country VARCHAR(2) DEFAULT 'DK',
|
|
||||||
ADD COLUMN IF NOT EXISTS website VARCHAR(255),
|
|
||||||
ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- vTiger integration
|
|
||||||
ADD COLUMN IF NOT EXISTS vtiger_id VARCHAR(50) UNIQUE,
|
|
||||||
ADD COLUMN IF NOT EXISTS vtiger_account_no VARCHAR(50),
|
|
||||||
ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMP,
|
|
||||||
|
|
||||||
-- E-conomic integration
|
|
||||||
ADD COLUMN IF NOT EXISTS economic_customer_number INTEGER,
|
|
||||||
ADD COLUMN IF NOT EXISTS payment_terms_number INTEGER,
|
|
||||||
ADD COLUMN IF NOT EXISTS payment_terms_type VARCHAR(50),
|
|
||||||
ADD COLUMN IF NOT EXISTS vat_zone_number INTEGER,
|
|
||||||
ADD COLUMN IF NOT EXISTS customer_group_number INTEGER,
|
|
||||||
ADD COLUMN IF NOT EXISTS barred BOOLEAN DEFAULT FALSE,
|
|
||||||
ADD COLUMN IF NOT EXISTS currency_code VARCHAR(3) DEFAULT 'DKK',
|
|
||||||
ADD COLUMN IF NOT EXISTS ean VARCHAR(13),
|
|
||||||
ADD COLUMN IF NOT EXISTS public_entry_number VARCHAR(50),
|
|
||||||
|
|
||||||
-- Additional contact info
|
|
||||||
ADD COLUMN IF NOT EXISTS invoice_email VARCHAR(255),
|
|
||||||
ADD COLUMN IF NOT EXISTS mobile_phone VARCHAR(50),
|
|
||||||
|
|
||||||
-- Customer relationships
|
|
||||||
ADD COLUMN IF NOT EXISTS parent_customer_id INTEGER REFERENCES customers(id),
|
|
||||||
ADD COLUMN IF NOT EXISTS billing_customer_id INTEGER REFERENCES customers(id);
|
|
||||||
|
|
||||||
-- Opret nye indexes
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customers_cvr ON customers(cvr_number);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customers_vtiger ON customers(vtiger_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customers_economic ON customers(economic_customer_number);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customers_active ON customers(is_active);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customers_parent ON customers(parent_customer_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customers_billing ON customers(billing_customer_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customers_city ON customers(city);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customers_name ON customers(name);
|
|
||||||
|
|
||||||
-- Opdater trigger function hvis den ikke findes
|
|
||||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Tilføj trigger til customers (hvis ikke allerede eksisterer)
|
|
||||||
DROP TRIGGER IF EXISTS update_customers_updated_at ON customers;
|
|
||||||
CREATE TRIGGER update_customers_updated_at
|
|
||||||
BEFORE UPDATE ON customers
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_updated_at_column();
|
|
||||||
|
|
||||||
-- Tilføj kommentarer til dokumentation
|
|
||||||
COMMENT ON COLUMN customers.cvr_number IS 'Danish CVR (company registration) number';
|
|
||||||
COMMENT ON COLUMN customers.email_domain IS 'Primary email domain for customer';
|
|
||||||
COMMENT ON COLUMN customers.vtiger_id IS 'Reference to vTiger CRM account ID';
|
|
||||||
COMMENT ON COLUMN customers.economic_customer_number IS 'Reference to e-conomic customer number';
|
|
||||||
COMMENT ON COLUMN customers.parent_customer_id IS 'Parent company if part of a group';
|
|
||||||
COMMENT ON COLUMN customers.billing_customer_id IS 'Billing account if different from this customer';
|
|
||||||
COMMENT ON COLUMN customers.barred IS 'Whether customer is barred from ordering';
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
-- Migration 004: Opret contacts og customer relationships tabeller
|
|
||||||
-- Dato: 6. december 2025
|
|
||||||
|
|
||||||
-- Contacts tabel
|
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
first_name VARCHAR(100) NOT NULL,
|
|
||||||
last_name VARCHAR(100) NOT NULL,
|
|
||||||
email VARCHAR(255),
|
|
||||||
phone VARCHAR(50),
|
|
||||||
mobile VARCHAR(50),
|
|
||||||
title VARCHAR(100),
|
|
||||||
department VARCHAR(100),
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
vtiger_id VARCHAR(50) UNIQUE,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Junction tabel for mange-til-mange relation mellem contacts og customers
|
|
||||||
CREATE TABLE IF NOT EXISTS contact_companies (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
||||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
||||||
is_primary BOOLEAN DEFAULT FALSE,
|
|
||||||
role VARCHAR(100),
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(contact_id, customer_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Customer relationships tabel (for parent/child og billing strukturer)
|
|
||||||
CREATE TABLE IF NOT EXISTS customer_relationships (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
parent_customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
||||||
child_customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
||||||
relationship_type VARCHAR(50) NOT NULL CHECK (relationship_type IN ('parent_child', 'billing', 'partnership', 'subsidiary')),
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(parent_customer_id, child_customer_id, relationship_type)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Indexes for contacts
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_contacts_active ON contacts(is_active);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_contacts_vtiger ON contacts(vtiger_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(first_name, last_name);
|
|
||||||
|
|
||||||
-- Indexes for contact_companies
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_contact_companies_contact ON contact_companies(contact_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_contact_companies_customer ON contact_companies(customer_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_contact_companies_primary ON contact_companies(customer_id, is_primary);
|
|
||||||
|
|
||||||
-- Indexes for customer_relationships
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customer_relationships_parent ON customer_relationships(parent_customer_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customer_relationships_child ON customer_relationships(child_customer_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customer_relationships_type ON customer_relationships(relationship_type);
|
|
||||||
|
|
||||||
-- Triggers for updated_at
|
|
||||||
DROP TRIGGER IF EXISTS update_contacts_updated_at ON contacts;
|
|
||||||
CREATE TRIGGER update_contacts_updated_at
|
|
||||||
BEFORE UPDATE ON contacts
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_updated_at_column();
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS update_customer_relationships_updated_at ON customer_relationships;
|
|
||||||
CREATE TRIGGER update_customer_relationships_updated_at
|
|
||||||
BEFORE UPDATE ON customer_relationships
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_updated_at_column();
|
|
||||||
|
|
||||||
-- Kommentarer
|
|
||||||
COMMENT ON TABLE contacts IS 'Contact persons associated with one or more customers';
|
|
||||||
COMMENT ON TABLE contact_companies IS 'Many-to-many junction table linking contacts to customers';
|
|
||||||
COMMENT ON TABLE customer_relationships IS 'Hierarchical and billing relationships between customers';
|
|
||||||
COMMENT ON COLUMN contact_companies.is_primary IS 'Whether this is the primary contact for this customer';
|
|
||||||
COMMENT ON COLUMN customer_relationships.relationship_type IS 'Type of relationship: parent_child, billing, partnership, subsidiary';
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
-- Migration: Add vendors table
|
|
||||||
-- Similar to customers but for suppliers/vendors
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS vendors (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
cvr_number VARCHAR(20) UNIQUE,
|
|
||||||
|
|
||||||
-- Contact information
|
|
||||||
email VARCHAR(255),
|
|
||||||
phone VARCHAR(50),
|
|
||||||
website VARCHAR(255),
|
|
||||||
|
|
||||||
-- Address
|
|
||||||
address TEXT,
|
|
||||||
postal_code VARCHAR(10),
|
|
||||||
city VARCHAR(100),
|
|
||||||
country VARCHAR(100) DEFAULT 'Danmark',
|
|
||||||
|
|
||||||
-- Integration IDs
|
|
||||||
economic_supplier_number INTEGER UNIQUE,
|
|
||||||
|
|
||||||
-- Vendor specific
|
|
||||||
domain VARCHAR(255),
|
|
||||||
email_pattern TEXT,
|
|
||||||
category VARCHAR(50) DEFAULT 'general',
|
|
||||||
priority INTEGER DEFAULT 50,
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
notes TEXT,
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by_user_id INTEGER,
|
|
||||||
updated_by_user_id INTEGER
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Indexes
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_vendors_name ON vendors(name);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_vendors_cvr ON vendors(cvr_number);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_vendors_domain ON vendors(domain);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_vendors_active ON vendors(is_active);
|
|
||||||
|
|
||||||
-- Updated timestamp trigger
|
|
||||||
CREATE OR REPLACE FUNCTION update_vendors_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER vendors_updated_at_trigger
|
|
||||||
BEFORE UPDATE ON vendors
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_vendors_updated_at();
|
|
||||||
|
|
||||||
COMMENT ON TABLE vendors IS 'Suppliers and vendors';
|
|
||||||
COMMENT ON COLUMN vendors.category IS 'Vendor category: hardware, software, telecom, services, etc.';
|
|
||||||
COMMENT ON COLUMN vendors.priority IS 'Priority level 1-100, higher = more important';
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
-- Migration: Settings and User Management
|
|
||||||
-- Add settings table and extend users table
|
|
||||||
|
|
||||||
-- Settings table for system configuration
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
key VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
value TEXT,
|
|
||||||
category VARCHAR(100) DEFAULT 'general',
|
|
||||||
description TEXT,
|
|
||||||
value_type VARCHAR(50) DEFAULT 'string', -- string, boolean, integer, json
|
|
||||||
is_public BOOLEAN DEFAULT false, -- Can be read by non-admin users
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_by_user_id INTEGER
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Default settings
|
|
||||||
INSERT INTO settings (key, value, category, description, value_type, is_public) VALUES
|
|
||||||
('company_name', 'BMC Networks', 'company', 'Firmanavn', 'string', true),
|
|
||||||
('company_cvr', '', 'company', 'CVR-nummer', 'string', false),
|
|
||||||
('company_email', 'info@bmcnetworks.dk', 'company', 'Firma email', 'string', true),
|
|
||||||
('company_phone', '', 'company', 'Firma telefon', 'string', true),
|
|
||||||
('company_address', '', 'company', 'Firma adresse', 'string', false),
|
|
||||||
('vtiger_enabled', 'false', 'integrations', 'vTiger integration aktiv', 'boolean', false),
|
|
||||||
('vtiger_url', '', 'integrations', 'vTiger URL', 'string', false),
|
|
||||||
('vtiger_username', '', 'integrations', 'vTiger brugernavn', 'string', false),
|
|
||||||
('economic_enabled', 'false', 'integrations', 'e-conomic integration aktiv', 'boolean', false),
|
|
||||||
('economic_app_secret', '', 'integrations', 'e-conomic App Secret Token', 'string', false),
|
|
||||||
('economic_agreement_token', '', 'integrations', 'e-conomic Agreement Grant Token', 'string', false),
|
|
||||||
('email_notifications', 'true', 'notifications', 'Email notifikationer', 'boolean', true),
|
|
||||||
('system_timezone', 'Europe/Copenhagen', 'system', 'Tidszone', 'string', true)
|
|
||||||
ON CONFLICT (key) DO NOTHING;
|
|
||||||
|
|
||||||
-- Extend users table with more fields (if not already added by auth migration)
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='is_active') THEN
|
|
||||||
ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT true;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='last_login') THEN
|
|
||||||
ALTER TABLE users ADD COLUMN last_login TIMESTAMP;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='created_at') THEN
|
|
||||||
ALTER TABLE users ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='updated_at') THEN
|
|
||||||
ALTER TABLE users ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- Indexes
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_settings_category ON settings(category);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_settings_key ON settings(key);
|
|
||||||
|
|
||||||
-- Updated timestamp trigger for settings
|
|
||||||
CREATE OR REPLACE FUNCTION update_settings_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER settings_updated_at_trigger
|
|
||||||
BEFORE UPDATE ON settings
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_settings_updated_at();
|
|
||||||
|
|
||||||
COMMENT ON TABLE settings IS 'System configuration and settings';
|
|
||||||
COMMENT ON COLUMN settings.value_type IS 'Data type: string, boolean, integer, json';
|
|
||||||
COMMENT ON COLUMN settings.is_public IS 'Whether non-admin users can read this setting';
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
-- DEV Portal Migration
|
|
||||||
-- Features roadmap and workflow diagrams
|
|
||||||
|
|
||||||
-- Features/Roadmap table
|
|
||||||
CREATE TABLE IF NOT EXISTS dev_features (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
version VARCHAR(50), -- V1, V2, V3, etc.
|
|
||||||
status VARCHAR(50) DEFAULT 'planlagt', -- planlagt, i gang, færdig, sat på pause
|
|
||||||
priority INTEGER DEFAULT 50,
|
|
||||||
expected_date DATE,
|
|
||||||
completed_date DATE,
|
|
||||||
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Brainstorm/Ideas table
|
|
||||||
CREATE TABLE IF NOT EXISTS dev_ideas (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
category VARCHAR(100), -- feature, improvement, bugfix, etc.
|
|
||||||
votes INTEGER DEFAULT 0,
|
|
||||||
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Workflow diagrams table (storing draw.io XML)
|
|
||||||
CREATE TABLE IF NOT EXISTS dev_workflows (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
category VARCHAR(100), -- flowchart, process, system_diagram, etc.
|
|
||||||
diagram_xml TEXT NOT NULL, -- draw.io XML format
|
|
||||||
thumbnail_url TEXT, -- Preview image URL (optional)
|
|
||||||
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Indexes
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dev_features_version ON dev_features(version);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dev_features_status ON dev_features(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dev_workflows_category ON dev_workflows(category);
|
|
||||||
|
|
||||||
-- Update timestamp trigger for dev_features
|
|
||||||
CREATE OR REPLACE FUNCTION update_dev_features_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER dev_features_updated_at_trigger
|
|
||||||
BEFORE UPDATE ON dev_features
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_dev_features_updated_at();
|
|
||||||
|
|
||||||
-- Update timestamp trigger for dev_ideas
|
|
||||||
CREATE OR REPLACE FUNCTION update_dev_ideas_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER dev_ideas_updated_at_trigger
|
|
||||||
BEFORE UPDATE ON dev_ideas
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_dev_ideas_updated_at();
|
|
||||||
|
|
||||||
-- Update timestamp trigger for dev_workflows
|
|
||||||
CREATE OR REPLACE FUNCTION update_dev_workflows_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER dev_workflows_updated_at_trigger
|
|
||||||
BEFORE UPDATE ON dev_workflows
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_dev_workflows_updated_at();
|
|
||||||
|
|
||||||
-- Sample data
|
|
||||||
INSERT INTO dev_features (title, description, version, status, priority, expected_date) VALUES
|
|
||||||
('Dashboard Forbedringer', 'Live data og bedre statistik visualisering', 'V1', 'færdig', 90, '2025-12-01'),
|
|
||||||
('Global Søgning', 'Søg på tværs af kunder, kontakter, leverandører med CVR/telefon', 'V1', 'færdig', 95, '2025-12-06'),
|
|
||||||
('Settings & Brugerstyring', 'Konfiguration og user management', 'V1', 'færdig', 85, '2025-12-06'),
|
|
||||||
('vTiger Integration', 'Synkronisering med vTiger CRM', 'V2', 'planlagt', 80, '2026-01-15'),
|
|
||||||
('e-conomic Integration', 'Fakturering og økonomi sync', 'V2', 'planlagt', 75, '2026-02-01'),
|
|
||||||
('Rapport Generator', 'PDF rapporter for kunder', 'V3', 'planlagt', 60, '2026-03-01');
|
|
||||||
|
|
||||||
INSERT INTO dev_ideas (title, description, category, votes) VALUES
|
|
||||||
('Dark mode forbedringer', 'Bedre kontrast i dark mode', 'improvement', 5),
|
|
||||||
('Mobile app', 'Native iOS/Android app', 'feature', 12),
|
|
||||||
('AI Assistent', 'ChatGPT integration til kundesupport', 'feature', 8),
|
|
||||||
('Eksport til Excel', 'Eksporter alle lister til Excel', 'feature', 15);
|
|
||||||
|
|
||||||
COMMENT ON TABLE dev_features IS 'Development roadmap features with versioning and status tracking';
|
|
||||||
COMMENT ON TABLE dev_ideas IS 'Brainstorm and idea collection for future development';
|
|
||||||
COMMENT ON TABLE dev_workflows IS 'Workflow diagrams created with draw.io, stored as XML';
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
-- Migration 008: Add support for credit notes
|
|
||||||
-- Add invoice_type column to distinguish between invoices and credit notes
|
|
||||||
|
|
||||||
ALTER TABLE supplier_invoices
|
|
||||||
ADD COLUMN IF NOT EXISTS invoice_type VARCHAR(20) DEFAULT 'invoice' CHECK (invoice_type IN ('invoice', 'credit_note'));
|
|
||||||
|
|
||||||
-- Update existing records to be 'invoice' type
|
|
||||||
UPDATE supplier_invoices SET invoice_type = 'invoice' WHERE invoice_type IS NULL;
|
|
||||||
|
|
||||||
-- Add index for filtering by type
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_type ON supplier_invoices(invoice_type);
|
|
||||||
|
|
||||||
-- Add document_type to extractions table
|
|
||||||
ALTER TABLE extractions
|
|
||||||
ADD COLUMN IF NOT EXISTS document_type_detected VARCHAR(20) CHECK (document_type_detected IN ('invoice', 'credit_note', 'receipt', 'other'));
|
|
||||||
|
|
||||||
-- Update system prompt context
|
|
||||||
COMMENT ON COLUMN supplier_invoices.invoice_type IS 'Type of document: invoice or credit_note';
|
|
||||||
COMMENT ON COLUMN extractions.document_type_detected IS 'AI-detected document type from extraction';
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
-- Migration 008: Supplier Invoices (Kassekladde / Leverandørfakturaer)
|
|
||||||
-- Tables for tracking invoices WE RECEIVE from vendors (incoming invoices we must pay)
|
|
||||||
-- Different from customer invoices which track invoices WE SEND to customers
|
|
||||||
-- Integrates with e-conomic kassekladde (journals/vouchers API)
|
|
||||||
|
|
||||||
-- Main supplier invoices table
|
|
||||||
CREATE TABLE IF NOT EXISTS supplier_invoices (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
invoice_number VARCHAR(100) NOT NULL,
|
|
||||||
vendor_id INTEGER REFERENCES vendors(id),
|
|
||||||
vendor_name VARCHAR(255),
|
|
||||||
invoice_date DATE NOT NULL,
|
|
||||||
due_date DATE,
|
|
||||||
total_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
vat_amount DECIMAL(15, 2) DEFAULT 0,
|
|
||||||
net_amount DECIMAL(15, 2) DEFAULT 0,
|
|
||||||
currency VARCHAR(10) DEFAULT 'DKK',
|
|
||||||
description TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
status VARCHAR(50) DEFAULT 'pending', -- pending, approved, sent_to_economic, paid, overdue, cancelled
|
|
||||||
|
|
||||||
-- e-conomic integration fields
|
|
||||||
economic_supplier_number INTEGER, -- Supplier number in e-conomic
|
|
||||||
economic_journal_number INTEGER, -- Kassekladde number used
|
|
||||||
economic_voucher_number INTEGER, -- Voucher number in e-conomic
|
|
||||||
economic_accounting_year VARCHAR(4), -- Accounting year (e.g., "2025")
|
|
||||||
sent_to_economic_at TIMESTAMP,
|
|
||||||
|
|
||||||
-- Payment tracking
|
|
||||||
paid_date DATE,
|
|
||||||
payment_reference VARCHAR(100),
|
|
||||||
|
|
||||||
-- Approval workflow
|
|
||||||
approved_by VARCHAR(255),
|
|
||||||
approved_at TIMESTAMP,
|
|
||||||
|
|
||||||
-- File attachments
|
|
||||||
file_path VARCHAR(500), -- Path to uploaded PDF/invoice file
|
|
||||||
attachment_url VARCHAR(500), -- URL to external attachment
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
created_by VARCHAR(255),
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Line items for supplier invoices with VAT breakdown
|
|
||||||
CREATE TABLE IF NOT EXISTS supplier_invoice_lines (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
|
|
||||||
line_number INTEGER,
|
|
||||||
description TEXT,
|
|
||||||
quantity DECIMAL(10, 2) DEFAULT 1,
|
|
||||||
unit_price DECIMAL(15, 2) DEFAULT 0,
|
|
||||||
line_total DECIMAL(15, 2) DEFAULT 0,
|
|
||||||
|
|
||||||
-- VAT details per line
|
|
||||||
vat_code VARCHAR(20) DEFAULT 'I25', -- e-conomic VAT codes: I25, I0, IY25, IYEU, IVEU, etc.
|
|
||||||
vat_rate DECIMAL(5, 2) DEFAULT 25.00,
|
|
||||||
vat_amount DECIMAL(15, 2) DEFAULT 0,
|
|
||||||
|
|
||||||
-- e-conomic account mapping
|
|
||||||
contra_account VARCHAR(10) DEFAULT '5810', -- Default expense account
|
|
||||||
|
|
||||||
-- Product linking (optional)
|
|
||||||
product_id INTEGER,
|
|
||||||
sku VARCHAR(100),
|
|
||||||
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- System settings for e-conomic kassekladde integration
|
|
||||||
-- Add to existing system_settings table if it exists, or track separately
|
|
||||||
CREATE TABLE IF NOT EXISTS supplier_invoice_settings (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
setting_key VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
setting_value TEXT,
|
|
||||||
description TEXT,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Insert default settings for kassekladde
|
|
||||||
INSERT INTO supplier_invoice_settings (setting_key, setting_value, description)
|
|
||||||
VALUES
|
|
||||||
('economic_default_journal', '1', 'Default kassekladde nummer til leverandørfakturaer'),
|
|
||||||
('economic_default_contra_account', '5810', 'Default expense account (drift/materialer)'),
|
|
||||||
('auto_approve_under_amount', '1000', 'Auto-approve invoices under this amount (DKK)'),
|
|
||||||
('require_attachment', 'true', 'Require PDF attachment before sending to e-conomic')
|
|
||||||
ON CONFLICT (setting_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- Indexes for performance
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_vendor ON supplier_invoices(vendor_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_status ON supplier_invoices(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_due_date ON supplier_invoices(due_date);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_economic_voucher ON supplier_invoices(economic_voucher_number);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_invoice ON supplier_invoice_lines(supplier_invoice_id);
|
|
||||||
|
|
||||||
-- Trigger to update updated_at timestamp
|
|
||||||
CREATE OR REPLACE FUNCTION update_supplier_invoice_timestamp()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER supplier_invoices_updated_at
|
|
||||||
BEFORE UPDATE ON supplier_invoices
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_supplier_invoice_timestamp();
|
|
||||||
|
|
||||||
-- View for overdue supplier invoices
|
|
||||||
CREATE OR REPLACE VIEW overdue_supplier_invoices AS
|
|
||||||
SELECT
|
|
||||||
si.*,
|
|
||||||
v.name as vendor_full_name,
|
|
||||||
v.economic_supplier_number as vendor_economic_id,
|
|
||||||
(CURRENT_DATE - si.due_date) as days_overdue
|
|
||||||
FROM supplier_invoices si
|
|
||||||
LEFT JOIN vendors v ON si.vendor_id = v.id
|
|
||||||
WHERE si.status IN ('pending', 'approved')
|
|
||||||
AND si.due_date < CURRENT_DATE
|
|
||||||
ORDER BY si.due_date ASC;
|
|
||||||
|
|
||||||
-- View for pending e-conomic sync
|
|
||||||
CREATE OR REPLACE VIEW pending_economic_sync AS
|
|
||||||
SELECT
|
|
||||||
si.*,
|
|
||||||
v.name as vendor_full_name,
|
|
||||||
v.economic_supplier_number as vendor_economic_id,
|
|
||||||
COUNT(sil.id) as line_count
|
|
||||||
FROM supplier_invoices si
|
|
||||||
LEFT JOIN vendors v ON si.vendor_id = v.id
|
|
||||||
LEFT JOIN supplier_invoice_lines sil ON si.id = sil.supplier_invoice_id
|
|
||||||
WHERE si.status = 'approved'
|
|
||||||
AND si.economic_voucher_number IS NULL
|
|
||||||
AND (
|
|
||||||
(SELECT setting_value FROM supplier_invoice_settings WHERE setting_key = 'require_attachment') = 'false'
|
|
||||||
OR si.file_path IS NOT NULL
|
|
||||||
)
|
|
||||||
GROUP BY si.id, v.name, v.economic_supplier_number
|
|
||||||
ORDER BY si.invoice_date ASC;
|
|
||||||
|
|
||||||
COMMENT ON TABLE supplier_invoices IS 'Leverandørfakturaer - invoices received from vendors that we must pay';
|
|
||||||
COMMENT ON TABLE supplier_invoice_lines IS 'Line items for supplier invoices with VAT and account details';
|
|
||||||
COMMENT ON TABLE supplier_invoice_settings IS 'System settings for supplier invoice and e-conomic kassekladde integration';
|
|
||||||
COMMENT ON VIEW overdue_supplier_invoices IS 'All unpaid supplier invoices past their due date';
|
|
||||||
COMMENT ON VIEW pending_economic_sync IS 'Approved supplier invoices ready to be sent to e-conomic kassekladde';
|
|
||||||
@ -1,133 +0,0 @@
|
|||||||
-- Migration 009: Document Extraction and Upload System
|
|
||||||
-- Adds tables for file upload tracking, AI extraction, and duplicate prevention
|
|
||||||
|
|
||||||
-- Table: incoming_files
|
|
||||||
-- Tracks all uploaded files with SHA256 checksums for duplicate detection
|
|
||||||
CREATE TABLE IF NOT EXISTS incoming_files (
|
|
||||||
file_id SERIAL PRIMARY KEY,
|
|
||||||
filename VARCHAR(500) NOT NULL,
|
|
||||||
original_filename VARCHAR(500) NOT NULL,
|
|
||||||
file_path VARCHAR(1000),
|
|
||||||
file_size INTEGER,
|
|
||||||
mime_type VARCHAR(100),
|
|
||||||
checksum VARCHAR(64) NOT NULL UNIQUE, -- SHA256 hash for duplicate detection
|
|
||||||
status VARCHAR(50) DEFAULT 'pending', -- pending, processing, processed, failed, duplicate
|
|
||||||
uploaded_by INTEGER, -- Future: REFERENCES users(user_id)
|
|
||||||
uploaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
processed_at TIMESTAMP,
|
|
||||||
error_message TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_incoming_files_status ON incoming_files(status);
|
|
||||||
CREATE INDEX idx_incoming_files_checksum ON incoming_files(checksum);
|
|
||||||
CREATE INDEX idx_incoming_files_uploaded_at ON incoming_files(uploaded_at DESC);
|
|
||||||
|
|
||||||
-- Table: extractions
|
|
||||||
-- Stores AI-extracted data from uploaded documents
|
|
||||||
CREATE TABLE IF NOT EXISTS extractions (
|
|
||||||
extraction_id SERIAL PRIMARY KEY,
|
|
||||||
file_id INTEGER REFERENCES incoming_files(file_id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Document metadata
|
|
||||||
document_type VARCHAR(100), -- invoice, purchase_order, delivery_note, credit_note
|
|
||||||
document_id VARCHAR(100), -- Invoice/order number extracted by AI
|
|
||||||
vendor_name VARCHAR(255), -- Vendor name extracted by AI
|
|
||||||
vendor_cvr VARCHAR(20), -- CVR number extracted by AI
|
|
||||||
vendor_matched_id INTEGER REFERENCES vendors(id), -- Matched vendor from database
|
|
||||||
match_confidence DECIMAL(5,4) CHECK (match_confidence >= 0.0 AND match_confidence <= 1.0),
|
|
||||||
|
|
||||||
-- Financial data
|
|
||||||
document_date DATE,
|
|
||||||
due_date DATE,
|
|
||||||
currency VARCHAR(10) DEFAULT 'DKK',
|
|
||||||
total_amount DECIMAL(12,2),
|
|
||||||
vat_amount DECIMAL(12,2),
|
|
||||||
|
|
||||||
-- AI metadata
|
|
||||||
confidence DECIMAL(5,4) CHECK (confidence >= 0.0 AND confidence <= 1.0),
|
|
||||||
raw_text_snippet TEXT,
|
|
||||||
llm_response_json TEXT, -- Full JSON response from Ollama
|
|
||||||
|
|
||||||
-- Processing status
|
|
||||||
status VARCHAR(50) DEFAULT 'extracted', -- extracted, validated, matched, needs_review, converted
|
|
||||||
reviewed_by INTEGER, -- Future: REFERENCES users(user_id)
|
|
||||||
reviewed_at TIMESTAMP,
|
|
||||||
|
|
||||||
-- Timestamps
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_extractions_file_id ON extractions(file_id);
|
|
||||||
CREATE INDEX idx_extractions_vendor_matched_id ON extractions(vendor_matched_id);
|
|
||||||
CREATE INDEX idx_extractions_status ON extractions(status);
|
|
||||||
CREATE INDEX idx_extractions_document_id ON extractions(document_id);
|
|
||||||
CREATE INDEX idx_extractions_vendor_cvr ON extractions(vendor_cvr);
|
|
||||||
|
|
||||||
-- Table: extraction_lines
|
|
||||||
-- Stores individual line items extracted from invoices
|
|
||||||
CREATE TABLE IF NOT EXISTS extraction_lines (
|
|
||||||
line_id SERIAL PRIMARY KEY,
|
|
||||||
extraction_id INTEGER NOT NULL REFERENCES extractions(extraction_id) ON DELETE CASCADE,
|
|
||||||
line_number INTEGER NOT NULL,
|
|
||||||
|
|
||||||
-- Product identification
|
|
||||||
sku VARCHAR(100),
|
|
||||||
ean VARCHAR(13), -- 13-digit EAN barcode
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Quantities and pricing
|
|
||||||
quantity DECIMAL(10,2),
|
|
||||||
unit_price DECIMAL(12,4),
|
|
||||||
line_total DECIMAL(12,2),
|
|
||||||
vat_rate DECIMAL(5,2), -- VAT percentage (25.00 for 25%)
|
|
||||||
vat_amount DECIMAL(12,2),
|
|
||||||
|
|
||||||
-- AI metadata
|
|
||||||
confidence DECIMAL(5,4) CHECK (confidence >= 0.0 AND confidence <= 1.0),
|
|
||||||
|
|
||||||
-- Timestamps
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
UNIQUE(extraction_id, line_number)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_extraction_lines_extraction_id ON extraction_lines(extraction_id);
|
|
||||||
|
|
||||||
-- Add file_id column to supplier_invoices (link to uploaded file)
|
|
||||||
ALTER TABLE supplier_invoices
|
|
||||||
ADD COLUMN IF NOT EXISTS extraction_id INTEGER REFERENCES extractions(extraction_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_supplier_invoices_extraction_id ON supplier_invoices(extraction_id);
|
|
||||||
|
|
||||||
-- Add UNIQUE constraint to prevent duplicate invoices (same vendor + invoice number)
|
|
||||||
-- Drop existing index if it exists
|
|
||||||
DROP INDEX IF EXISTS idx_supplier_invoices_vendor_invoice;
|
|
||||||
|
|
||||||
-- Create UNIQUE index on vendor_id + invoice_number (simple version without WHERE clause)
|
|
||||||
CREATE UNIQUE INDEX idx_supplier_invoices_vendor_invoice
|
|
||||||
ON supplier_invoices(vendor_id, invoice_number);
|
|
||||||
|
|
||||||
-- Trigger to update extractions.updated_at
|
|
||||||
CREATE OR REPLACE FUNCTION update_extractions_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trigger_update_extractions_updated_at ON extractions;
|
|
||||||
CREATE TRIGGER trigger_update_extractions_updated_at
|
|
||||||
BEFORE UPDATE ON extractions
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_extractions_updated_at();
|
|
||||||
|
|
||||||
-- Comments
|
|
||||||
COMMENT ON TABLE incoming_files IS 'Tracks all uploaded files with SHA256 checksums for duplicate detection';
|
|
||||||
COMMENT ON TABLE extractions IS 'Stores AI-extracted data from uploaded documents with CVR vendor matching';
|
|
||||||
COMMENT ON TABLE extraction_lines IS 'Stores individual line items extracted from invoices';
|
|
||||||
COMMENT ON COLUMN incoming_files.checksum IS 'SHA256 hash of file content - prevents duplicate uploads';
|
|
||||||
COMMENT ON COLUMN extractions.vendor_cvr IS 'CVR number extracted by AI from invoice';
|
|
||||||
COMMENT ON COLUMN extractions.vendor_matched_id IS 'Matched vendor from vendors table based on CVR number';
|
|
||||||
COMMENT ON COLUMN extractions.match_confidence IS 'Confidence score for vendor matching (0.0-1.0)';
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
-- Migration 010: Supplier Invoice Templates
|
|
||||||
-- Template-based invoice recognition (inspired by OmniSync)
|
|
||||||
|
|
||||||
-- Vendor templates for automatic invoice field extraction
|
|
||||||
CREATE TABLE IF NOT EXISTS supplier_invoice_templates (
|
|
||||||
template_id SERIAL PRIMARY KEY,
|
|
||||||
vendor_id INTEGER REFERENCES vendors(id) ON DELETE CASCADE,
|
|
||||||
template_name VARCHAR(255) NOT NULL,
|
|
||||||
|
|
||||||
-- Detection patterns (JSON array of patterns to identify this template)
|
|
||||||
detection_patterns JSONB DEFAULT '[]',
|
|
||||||
-- Example: [
|
|
||||||
-- {"type": "text", "pattern": "BMC Denmark ApS", "weight": 0.5},
|
|
||||||
-- {"type": "cvr", "value": "12345678", "weight": 1.0}
|
|
||||||
-- ]
|
|
||||||
|
|
||||||
-- Field extraction rules (regex patterns with capture groups)
|
|
||||||
field_mappings JSONB DEFAULT '{}',
|
|
||||||
-- Example: {
|
|
||||||
-- "invoice_number": {"pattern": "Faktura\\s*:?\\s*(\\d+)", "group": 1},
|
|
||||||
-- "invoice_date": {"pattern": "(\\d{2}[/-]\\d{2}[/-]\\d{2,4})", "format": "DD/MM/YYYY", "group": 1},
|
|
||||||
-- "total_amount": {"pattern": "Total\\s*:?\\s*([\\d.,]+)", "group": 1}
|
|
||||||
-- }
|
|
||||||
|
|
||||||
-- Statistics
|
|
||||||
usage_count INTEGER DEFAULT 0,
|
|
||||||
success_count INTEGER DEFAULT 0,
|
|
||||||
last_used_at TIMESTAMP,
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
UNIQUE(vendor_id, template_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_supplier_invoice_templates_vendor ON supplier_invoice_templates(vendor_id);
|
|
||||||
CREATE INDEX idx_supplier_invoice_templates_active ON supplier_invoice_templates(is_active);
|
|
||||||
|
|
||||||
-- Add template reference to incoming_files
|
|
||||||
ALTER TABLE incoming_files ADD COLUMN IF NOT EXISTS template_id INTEGER REFERENCES supplier_invoice_templates(template_id);
|
|
||||||
|
|
||||||
-- Template usage tracking
|
|
||||||
CREATE TABLE IF NOT EXISTS template_usage_log (
|
|
||||||
log_id SERIAL PRIMARY KEY,
|
|
||||||
template_id INTEGER REFERENCES supplier_invoice_templates(template_id) ON DELETE CASCADE,
|
|
||||||
file_id INTEGER REFERENCES incoming_files(file_id) ON DELETE CASCADE,
|
|
||||||
matched BOOLEAN DEFAULT FALSE,
|
|
||||||
confidence DECIMAL(3,2),
|
|
||||||
fields_extracted JSONB,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_template_usage_log_template ON template_usage_log(template_id);
|
|
||||||
CREATE INDEX idx_template_usage_log_file ON template_usage_log(file_id);
|
|
||||||
|
|
||||||
COMMENT ON TABLE supplier_invoice_templates IS 'Templates for automatic invoice field extraction per vendor';
|
|
||||||
COMMENT ON COLUMN supplier_invoice_templates.detection_patterns IS 'JSON array of patterns to detect this template in PDF text';
|
|
||||||
COMMENT ON COLUMN supplier_invoice_templates.field_mappings IS 'JSON object with regex patterns to extract invoice fields';
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
-- Migration 011: Add context fields to extraction_lines
|
|
||||||
-- These fields capture additional context information from invoice line items
|
|
||||||
|
|
||||||
ALTER TABLE extraction_lines
|
|
||||||
ADD COLUMN IF NOT EXISTS ip_address VARCHAR(50),
|
|
||||||
ADD COLUMN IF NOT EXISTS contract_number VARCHAR(100),
|
|
||||||
ADD COLUMN IF NOT EXISTS location_street VARCHAR(255),
|
|
||||||
ADD COLUMN IF NOT EXISTS location_zip VARCHAR(10),
|
|
||||||
ADD COLUMN IF NOT EXISTS location_city VARCHAR(100);
|
|
||||||
|
|
||||||
-- Add index for contract number lookups
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_extraction_lines_contract_number ON extraction_lines(contract_number);
|
|
||||||
|
|
||||||
COMMENT ON COLUMN extraction_lines.ip_address IS 'IP address/subnet from line context (e.g., 152.115.56.192/27)';
|
|
||||||
COMMENT ON COLUMN extraction_lines.contract_number IS 'Contract number from line context (e.g., NKA-008225)';
|
|
||||||
COMMENT ON COLUMN extraction_lines.location_street IS 'Street address from line context';
|
|
||||||
COMMENT ON COLUMN extraction_lines.location_zip IS 'Zip code from line context';
|
|
||||||
COMMENT ON COLUMN extraction_lines.location_city IS 'City from line context';
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
-- Migration 011: Quick Analysis on Upload
|
|
||||||
-- Adds fields to store automatic CVR, document type, and document number detection
|
|
||||||
|
|
||||||
-- Add quick analysis fields to incoming_files
|
|
||||||
ALTER TABLE incoming_files
|
|
||||||
ADD COLUMN IF NOT EXISTS detected_cvr VARCHAR(8),
|
|
||||||
ADD COLUMN IF NOT EXISTS detected_vendor_id INTEGER REFERENCES vendors(id),
|
|
||||||
ADD COLUMN IF NOT EXISTS detected_document_type VARCHAR(20), -- 'invoice' or 'credit_note'
|
|
||||||
ADD COLUMN IF NOT EXISTS detected_document_number VARCHAR(100);
|
|
||||||
|
|
||||||
-- Add index for CVR lookups
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_incoming_files_detected_cvr ON incoming_files(detected_cvr);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_incoming_files_detected_vendor ON incoming_files(detected_vendor_id);
|
|
||||||
|
|
||||||
-- Add comments
|
|
||||||
COMMENT ON COLUMN incoming_files.detected_cvr IS 'Automatically detected CVR number from PDF text';
|
|
||||||
COMMENT ON COLUMN incoming_files.detected_vendor_id IS 'Vendor matched by CVR on upload';
|
|
||||||
COMMENT ON COLUMN incoming_files.detected_document_type IS 'Auto-detected: invoice or credit_note';
|
|
||||||
COMMENT ON COLUMN incoming_files.detected_document_number IS 'Automatically extracted invoice/credit note number';
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
-- Migration 012: Add is_own_invoice flag to filter outgoing invoices
|
|
||||||
-- BMC's own CVR: 29522790
|
|
||||||
|
|
||||||
-- Add column to track outgoing invoices (BMC's own invoices to customers)
|
|
||||||
ALTER TABLE incoming_files
|
|
||||||
ADD COLUMN IF NOT EXISTS is_own_invoice BOOLEAN DEFAULT FALSE;
|
|
||||||
|
|
||||||
-- Mark existing files with BMC's CVR as outgoing invoices
|
|
||||||
UPDATE incoming_files
|
|
||||||
SET is_own_invoice = TRUE
|
|
||||||
WHERE detected_cvr = '29522790';
|
|
||||||
|
|
||||||
-- Add index for faster filtering
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_incoming_files_is_own_invoice
|
|
||||||
ON incoming_files(is_own_invoice)
|
|
||||||
WHERE is_own_invoice = TRUE;
|
|
||||||
|
|
||||||
-- Add comment
|
|
||||||
COMMENT ON COLUMN incoming_files.is_own_invoice IS
|
|
||||||
'TRUE hvis filen er en udgående faktura fra BMC (CVR 29522790), FALSE hvis leverandør faktura';
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
-- Migration 012: Add default product category to templates
|
|
||||||
-- Allows templates to specify default category for line items (varesalg, drift, etc.)
|
|
||||||
|
|
||||||
ALTER TABLE supplier_invoice_templates
|
|
||||||
ADD COLUMN IF NOT EXISTS default_product_category VARCHAR(50) DEFAULT 'varesalg',
|
|
||||||
ADD COLUMN IF NOT EXISTS default_product_group_number INTEGER;
|
|
||||||
|
|
||||||
-- Valid categories: varesalg, drift, anlæg, abonnement, lager, udlejning
|
|
||||||
COMMENT ON COLUMN supplier_invoice_templates.default_product_category IS 'Default kategori for varelinjer: varesalg, drift, anlæg, abonnement, lager, udlejning';
|
|
||||||
COMMENT ON COLUMN supplier_invoice_templates.default_product_group_number IS 'Default e-conomic produktgruppe nummer';
|
|
||||||
|
|
||||||
-- Add index for category lookups
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_templates_category ON supplier_invoice_templates(default_product_category);
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
INFO: Will watch for changes in these directories: ['/Users/christianthomas/DEV/bmc_hub_dev']
|
|
||||||
ERROR: [Errno 48] Address already in use
|
|
||||||
@ -5,15 +5,3 @@ pydantic==2.10.3
|
|||||||
pydantic-settings==2.6.1
|
pydantic-settings==2.6.1
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
python-multipart==0.0.17
|
python-multipart==0.0.17
|
||||||
jinja2==3.1.4
|
|
||||||
pyjwt==2.9.0
|
|
||||||
aiohttp==3.10.10
|
|
||||||
|
|
||||||
# AI & Document Processing
|
|
||||||
httpx==0.27.2
|
|
||||||
PyPDF2==3.0.1
|
|
||||||
pdfplumber==0.11.4
|
|
||||||
pytesseract==0.3.13
|
|
||||||
Pillow==11.0.0
|
|
||||||
invoice2data==0.4.4
|
|
||||||
pyyaml==6.0.2
|
|
||||||
|
|||||||
@ -1,89 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Backfill quick analysis for existing files
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Add parent directory to path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_update, init_db
|
|
||||||
from app.services.ollama_service import ollama_service
|
|
||||||
|
|
||||||
|
|
||||||
async def backfill_quick_analysis():
|
|
||||||
"""Run quick analysis on all files that don't have it"""
|
|
||||||
|
|
||||||
# Initialize database
|
|
||||||
init_db()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get files without quick analysis
|
|
||||||
files = execute_query(
|
|
||||||
"""SELECT file_id, filename, file_path
|
|
||||||
FROM incoming_files
|
|
||||||
WHERE (detected_cvr IS NULL OR detected_document_number IS NULL)
|
|
||||||
AND status NOT IN ('duplicate')
|
|
||||||
AND file_path IS NOT NULL
|
|
||||||
ORDER BY file_id DESC"""
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"📋 Found {len(files)} files without quick analysis")
|
|
||||||
|
|
||||||
success_count = 0
|
|
||||||
fail_count = 0
|
|
||||||
|
|
||||||
for file in files:
|
|
||||||
try:
|
|
||||||
file_path = Path(file['file_path'])
|
|
||||||
|
|
||||||
if not file_path.exists():
|
|
||||||
print(f"⚠️ File not found: {file_path}")
|
|
||||||
fail_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f"\n🔍 Processing: {file['filename']} (ID: {file['file_id']})")
|
|
||||||
|
|
||||||
# Extract text
|
|
||||||
text = await ollama_service._extract_text_from_file(file_path)
|
|
||||||
|
|
||||||
# Run quick analysis
|
|
||||||
quick_result = await ollama_service.quick_analysis_on_upload(text)
|
|
||||||
|
|
||||||
# Update database
|
|
||||||
execute_update(
|
|
||||||
"""UPDATE incoming_files
|
|
||||||
SET detected_cvr = %s,
|
|
||||||
detected_vendor_id = %s,
|
|
||||||
detected_document_type = %s,
|
|
||||||
detected_document_number = %s
|
|
||||||
WHERE file_id = %s""",
|
|
||||||
(quick_result.get('cvr'),
|
|
||||||
quick_result.get('vendor_id'),
|
|
||||||
quick_result.get('document_type'),
|
|
||||||
quick_result.get('document_number'),
|
|
||||||
file['file_id'])
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"✅ Updated: CVR={quick_result.get('cvr')}, "
|
|
||||||
f"Type={quick_result.get('document_type')}, "
|
|
||||||
f"Number={quick_result.get('document_number')}, "
|
|
||||||
f"Vendor={quick_result.get('vendor_name')}")
|
|
||||||
|
|
||||||
success_count += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Error processing {file['filename']}: {e}")
|
|
||||||
fail_count += 1
|
|
||||||
|
|
||||||
print(f"\n📊 Summary: {success_count} successful, {fail_count} failed")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Fatal error: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(backfill_quick_analysis())
|
|
||||||
@ -1,245 +0,0 @@
|
|||||||
"""
|
|
||||||
Import customers and contacts from OmniSync database with better error handling
|
|
||||||
"""
|
|
||||||
|
|
||||||
import psycopg2
|
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
OMNISYNC_DB = '/omnisync_data/fakturering.db'
|
|
||||||
|
|
||||||
|
|
||||||
def get_postgres_connection():
|
|
||||||
"""Get PostgreSQL connection"""
|
|
||||||
database_url = os.getenv('DATABASE_URL', 'postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub')
|
|
||||||
return psycopg2.connect(database_url, cursor_factory=RealDictCursor)
|
|
||||||
|
|
||||||
|
|
||||||
def get_sqlite_connection():
|
|
||||||
"""Get SQLite connection to OmniSync database"""
|
|
||||||
return sqlite3.connect(OMNISYNC_DB)
|
|
||||||
|
|
||||||
|
|
||||||
def import_customers():
|
|
||||||
"""Import customers from OmniSync"""
|
|
||||||
sqlite_conn = get_sqlite_connection()
|
|
||||||
sqlite_cursor = sqlite_conn.cursor()
|
|
||||||
|
|
||||||
imported = 0
|
|
||||||
skipped = 0
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get active customers from OmniSync
|
|
||||||
sqlite_cursor.execute("""
|
|
||||||
SELECT
|
|
||||||
name, cvr_number, email, phone, address, city, postal_code,
|
|
||||||
country, website, vtiger_id, economic_customer_number,
|
|
||||||
email_domain, created_at
|
|
||||||
FROM customers
|
|
||||||
WHERE active = 1 AND deleted_at IS NULL
|
|
||||||
ORDER BY name
|
|
||||||
""")
|
|
||||||
|
|
||||||
customers = sqlite_cursor.fetchall()
|
|
||||||
logger.info(f"📥 Found {len(customers)} active customers in OmniSync")
|
|
||||||
|
|
||||||
for row in customers:
|
|
||||||
name, cvr, email, phone, address, city, postal_code, country, website, vtiger_id, economic_no, email_domain, created_at = row
|
|
||||||
|
|
||||||
# Skip if no name
|
|
||||||
if not name or name.strip() == '':
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Insert each customer individually
|
|
||||||
individual_conn = get_postgres_connection()
|
|
||||||
individual_cursor = individual_conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
individual_cursor.execute("""
|
|
||||||
INSERT INTO customers (
|
|
||||||
name, cvr_number, email, phone, address, postal_code, city,
|
|
||||||
country, website, vtiger_id, economic_customer_number,
|
|
||||||
email_domain, is_active, created_at
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
ON CONFLICT (cvr_number) DO UPDATE SET
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
email = EXCLUDED.email,
|
|
||||||
phone = EXCLUDED.phone,
|
|
||||||
address = EXCLUDED.address,
|
|
||||||
postal_code = EXCLUDED.postal_code,
|
|
||||||
city = EXCLUDED.city,
|
|
||||||
website = EXCLUDED.website,
|
|
||||||
vtiger_id = EXCLUDED.vtiger_id,
|
|
||||||
economic_customer_number = EXCLUDED.economic_customer_number
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
name, cvr, email, phone, address, postal_code, city,
|
|
||||||
country or 'Danmark', website, vtiger_id, economic_no,
|
|
||||||
email_domain, True, created_at
|
|
||||||
))
|
|
||||||
|
|
||||||
result = individual_cursor.fetchone()
|
|
||||||
individual_conn.commit()
|
|
||||||
|
|
||||||
if result:
|
|
||||||
imported += 1
|
|
||||||
if imported % 50 == 0:
|
|
||||||
logger.info(f" Imported {imported} customers...")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = str(e)[:100]
|
|
||||||
if imported + skipped < 10: # Only log first 10 errors in detail
|
|
||||||
logger.warning(f" ⚠️ Could not import '{name}': {error_msg}")
|
|
||||||
errors.append((name, error_msg))
|
|
||||||
skipped += 1
|
|
||||||
finally:
|
|
||||||
individual_cursor.close()
|
|
||||||
individual_conn.close()
|
|
||||||
|
|
||||||
logger.info(f"✅ Customers: {imported} imported, {skipped} skipped")
|
|
||||||
if len(errors) > 10:
|
|
||||||
logger.info(f" (Suppressed {len(errors)-10} error messages)")
|
|
||||||
|
|
||||||
return imported
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Customer import failed: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
sqlite_cursor.close()
|
|
||||||
sqlite_conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def import_contacts():
|
|
||||||
"""Import contacts from OmniSync"""
|
|
||||||
sqlite_conn = get_sqlite_connection()
|
|
||||||
sqlite_cursor = sqlite_conn.cursor()
|
|
||||||
|
|
||||||
imported = 0
|
|
||||||
skipped = 0
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get contacts
|
|
||||||
sqlite_cursor.execute("""
|
|
||||||
SELECT
|
|
||||||
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
|
|
||||||
c.title, c.department, c.active, c.vtiger_id, c.created_at
|
|
||||||
FROM contacts c
|
|
||||||
WHERE c.active = 1 AND c.deleted_at IS NULL
|
|
||||||
ORDER BY c.last_name, c.first_name
|
|
||||||
""")
|
|
||||||
|
|
||||||
contacts = sqlite_cursor.fetchall()
|
|
||||||
logger.info(f"📥 Found {len(contacts)} active contacts in OmniSync")
|
|
||||||
|
|
||||||
for row in contacts:
|
|
||||||
omnisync_id, first_name, last_name, email, phone, mobile, title, department, active, vtiger_id, created_at = row
|
|
||||||
|
|
||||||
# Skip if no name
|
|
||||||
if not first_name and not last_name:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Individual connection per contact
|
|
||||||
postgres_conn = get_postgres_connection()
|
|
||||||
postgres_cursor = postgres_conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Insert contact
|
|
||||||
postgres_cursor.execute("""
|
|
||||||
INSERT INTO contacts (
|
|
||||||
first_name, last_name, email, phone, mobile,
|
|
||||||
title, department, is_active, vtiger_id, created_at
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
first_name or '', last_name or '', email, phone, mobile,
|
|
||||||
title, department, bool(active), vtiger_id, created_at
|
|
||||||
))
|
|
||||||
|
|
||||||
result = postgres_cursor.fetchone()
|
|
||||||
contact_id = result['id']
|
|
||||||
|
|
||||||
# Get company relationships from OmniSync
|
|
||||||
sqlite_cursor.execute("""
|
|
||||||
SELECT customer_id, is_primary, role, notes
|
|
||||||
FROM contact_companies
|
|
||||||
WHERE contact_id = ?
|
|
||||||
""", (omnisync_id,))
|
|
||||||
|
|
||||||
companies = sqlite_cursor.fetchall()
|
|
||||||
|
|
||||||
# Link to companies in BMC Hub
|
|
||||||
for company_row in companies:
|
|
||||||
customer_id_omnisync, is_primary, role, notes = company_row
|
|
||||||
|
|
||||||
# Find matching customer in BMC Hub by vtiger_id or name
|
|
||||||
# First get the OmniSync customer
|
|
||||||
sqlite_cursor.execute("SELECT vtiger_id, name FROM customers WHERE id = ?", (customer_id_omnisync,))
|
|
||||||
customer_data = sqlite_cursor.fetchone()
|
|
||||||
|
|
||||||
if customer_data:
|
|
||||||
vtiger_id_customer, customer_name = customer_data
|
|
||||||
|
|
||||||
# Find in BMC Hub
|
|
||||||
if vtiger_id_customer:
|
|
||||||
postgres_cursor.execute("SELECT id FROM customers WHERE vtiger_id = %s", (vtiger_id_customer,))
|
|
||||||
else:
|
|
||||||
postgres_cursor.execute("SELECT id FROM customers WHERE name = %s LIMIT 1", (customer_name,))
|
|
||||||
|
|
||||||
bmc_customer = postgres_cursor.fetchone()
|
|
||||||
|
|
||||||
if bmc_customer:
|
|
||||||
postgres_cursor.execute("""
|
|
||||||
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role, notes)
|
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
""", (contact_id, bmc_customer['id'], bool(is_primary), role, notes))
|
|
||||||
|
|
||||||
postgres_conn.commit()
|
|
||||||
imported += 1
|
|
||||||
if imported % 50 == 0:
|
|
||||||
logger.info(f" Imported {imported} contacts...")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = str(e)[:100]
|
|
||||||
if imported + skipped < 10:
|
|
||||||
logger.warning(f" ⚠️ Could not import '{first_name} {last_name}': {error_msg}")
|
|
||||||
errors.append((f"{first_name} {last_name}", error_msg))
|
|
||||||
skipped += 1
|
|
||||||
finally:
|
|
||||||
postgres_cursor.close()
|
|
||||||
postgres_conn.close()
|
|
||||||
|
|
||||||
logger.info(f"✅ Contacts: {imported} imported, {skipped} skipped")
|
|
||||||
if len(errors) > 10:
|
|
||||||
logger.info(f" (Suppressed {len(errors)-10} error messages)")
|
|
||||||
|
|
||||||
return imported
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Contact import failed: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
sqlite_cursor.close()
|
|
||||||
sqlite_conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
logger.info("🚀 Starting OmniSync import...")
|
|
||||||
logger.info(f"📂 Source: {OMNISYNC_DB}")
|
|
||||||
|
|
||||||
customer_count = import_customers()
|
|
||||||
contact_count = import_contacts()
|
|
||||||
|
|
||||||
logger.info(f"\n🎉 Import completed!")
|
|
||||||
logger.info(f" Customers: {customer_count}")
|
|
||||||
logger.info(f" Contacts: {contact_count}")
|
|
||||||
@ -1,232 +0,0 @@
|
|||||||
"""
|
|
||||||
Data import script for BMC Hub
|
|
||||||
Imports customers and contacts from CSV files or creates sample data
|
|
||||||
"""
|
|
||||||
|
|
||||||
import psycopg2
|
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_connection():
|
|
||||||
"""Get database connection"""
|
|
||||||
database_url = os.getenv('DATABASE_URL', 'postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub')
|
|
||||||
return psycopg2.connect(database_url, cursor_factory=RealDictCursor)
|
|
||||||
|
|
||||||
|
|
||||||
def import_sample_data():
|
|
||||||
"""Import sample customers and contacts for testing"""
|
|
||||||
conn = get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Sample customers
|
|
||||||
customers = [
|
|
||||||
{
|
|
||||||
'name': 'TDC A/S',
|
|
||||||
'cvr_number': '14773908',
|
|
||||||
'address': 'Teglholmsgade 1',
|
|
||||||
'postal_code': '2450',
|
|
||||||
'city': 'København SV',
|
|
||||||
'email': 'info@tdc.dk',
|
|
||||||
'phone': '+45 70 70 40 30',
|
|
||||||
'website': 'https://tdc.dk',
|
|
||||||
'is_active': True
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'Dansk Supermarked Group',
|
|
||||||
'cvr_number': '16314439',
|
|
||||||
'address': 'Roskildevej 65',
|
|
||||||
'postal_code': '2620',
|
|
||||||
'city': 'Albertslund',
|
|
||||||
'email': 'info@dsg.dk',
|
|
||||||
'phone': '+45 43 86 43 86',
|
|
||||||
'website': 'https://dansksupermarked.dk',
|
|
||||||
'is_active': True
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'Nets Denmark A/S',
|
|
||||||
'cvr_number': '20016175',
|
|
||||||
'address': 'Lautrupbjerg 10',
|
|
||||||
'postal_code': '2750',
|
|
||||||
'city': 'Ballerup',
|
|
||||||
'email': 'info@nets.eu',
|
|
||||||
'phone': '+45 44 68 44 68',
|
|
||||||
'website': 'https://nets.eu',
|
|
||||||
'is_active': True
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'Salling Group',
|
|
||||||
'cvr_number': '30521736',
|
|
||||||
'address': 'Skanderborgvej 121',
|
|
||||||
'postal_code': '8260',
|
|
||||||
'city': 'Viby J',
|
|
||||||
'email': 'info@sallinggroup.com',
|
|
||||||
'phone': '+45 87 93 35 00',
|
|
||||||
'website': 'https://sallinggroup.com',
|
|
||||||
'is_active': True
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'ISS A/S',
|
|
||||||
'cvr_number': '28861341',
|
|
||||||
'address': 'Buddingevej 197',
|
|
||||||
'postal_code': '2860',
|
|
||||||
'city': 'Søborg',
|
|
||||||
'email': 'info@dk.issworld.com',
|
|
||||||
'phone': '+45 38 17 00 00',
|
|
||||||
'website': 'https://issworld.com',
|
|
||||||
'is_active': True
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.info("Importing customers...")
|
|
||||||
customer_ids = {}
|
|
||||||
|
|
||||||
for customer in customers:
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO customers (
|
|
||||||
name, cvr_number, address, postal_code, city,
|
|
||||||
email, phone, website, is_active
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
customer['name'], customer['cvr_number'], customer['address'],
|
|
||||||
customer['postal_code'], customer['city'], customer['email'],
|
|
||||||
customer['phone'], customer['website'], customer['is_active']
|
|
||||||
))
|
|
||||||
result = cursor.fetchone()
|
|
||||||
customer_id = result['id']
|
|
||||||
customer_ids[customer['name']] = customer_id
|
|
||||||
logger.info(f"✅ Imported customer: {customer['name']} (ID: {customer_id})")
|
|
||||||
|
|
||||||
# Sample contacts
|
|
||||||
contacts = [
|
|
||||||
{
|
|
||||||
'first_name': 'Lars',
|
|
||||||
'last_name': 'Jensen',
|
|
||||||
'email': 'lars.jensen@tdc.dk',
|
|
||||||
'phone': '+45 70 70 40 31',
|
|
||||||
'mobile': '+45 20 12 34 56',
|
|
||||||
'title': 'CTO',
|
|
||||||
'department': 'IT',
|
|
||||||
'companies': ['TDC A/S'],
|
|
||||||
'is_primary': True,
|
|
||||||
'role': 'Teknisk kontakt'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'first_name': 'Mette',
|
|
||||||
'last_name': 'Nielsen',
|
|
||||||
'email': 'mette.nielsen@dsg.dk',
|
|
||||||
'phone': '+45 43 86 43 87',
|
|
||||||
'mobile': '+45 30 98 76 54',
|
|
||||||
'title': 'IT Manager',
|
|
||||||
'department': 'IT Operations',
|
|
||||||
'companies': ['Dansk Supermarked Group'],
|
|
||||||
'is_primary': True,
|
|
||||||
'role': 'Primær kontakt'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'first_name': 'Peter',
|
|
||||||
'last_name': 'Hansen',
|
|
||||||
'email': 'peter.hansen@nets.eu',
|
|
||||||
'phone': '+45 44 68 44 69',
|
|
||||||
'mobile': '+45 40 11 22 33',
|
|
||||||
'title': 'Network Engineer',
|
|
||||||
'department': 'Infrastructure',
|
|
||||||
'companies': ['Nets Denmark A/S'],
|
|
||||||
'is_primary': True,
|
|
||||||
'role': 'Teknisk ansvarlig'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'first_name': 'Anne',
|
|
||||||
'last_name': 'Andersen',
|
|
||||||
'email': 'anne.andersen@sallinggroup.com',
|
|
||||||
'phone': '+45 87 93 35 01',
|
|
||||||
'mobile': '+45 50 44 55 66',
|
|
||||||
'title': 'IT Director',
|
|
||||||
'department': 'IT',
|
|
||||||
'companies': ['Salling Group'],
|
|
||||||
'is_primary': True,
|
|
||||||
'role': 'Beslutningsansvarlig'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'first_name': 'Thomas',
|
|
||||||
'last_name': 'Christensen',
|
|
||||||
'email': 'thomas.christensen@issworld.com',
|
|
||||||
'phone': '+45 38 17 00 01',
|
|
||||||
'mobile': '+45 60 77 88 99',
|
|
||||||
'title': 'Senior IT Consultant',
|
|
||||||
'department': 'IT Services',
|
|
||||||
'companies': ['ISS A/S', 'Nets Denmark A/S'], # Multi-company contact
|
|
||||||
'is_primary': False,
|
|
||||||
'role': 'Konsulent'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.info("Importing contacts...")
|
|
||||||
|
|
||||||
for contact in contacts:
|
|
||||||
# Insert contact
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO contacts (
|
|
||||||
first_name, last_name, email, phone, mobile,
|
|
||||||
title, department, is_active
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
contact['first_name'], contact['last_name'], contact['email'],
|
|
||||||
contact['phone'], contact['mobile'], contact['title'],
|
|
||||||
contact['department'], True
|
|
||||||
))
|
|
||||||
result = cursor.fetchone()
|
|
||||||
contact_id = result['id']
|
|
||||||
|
|
||||||
# Link to companies
|
|
||||||
for company_name in contact['companies']:
|
|
||||||
if company_name in customer_ids:
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO contact_companies (
|
|
||||||
contact_id, customer_id, is_primary, role
|
|
||||||
) VALUES (%s, %s, %s, %s)
|
|
||||||
""", (
|
|
||||||
contact_id, customer_ids[company_name],
|
|
||||||
contact['is_primary'] if contact['companies'][0] == company_name else False,
|
|
||||||
contact['role']
|
|
||||||
))
|
|
||||||
|
|
||||||
logger.info(f"✅ Imported contact: {contact['first_name']} {contact['last_name']} (ID: {contact_id}, Companies: {len(contact['companies'])})")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
logger.info("🎉 Sample data import completed successfully!")
|
|
||||||
|
|
||||||
# Print summary
|
|
||||||
cursor.execute("SELECT COUNT(*) as count FROM customers")
|
|
||||||
customer_count = cursor.fetchone()['count']
|
|
||||||
|
|
||||||
cursor.execute("SELECT COUNT(*) as count FROM contacts")
|
|
||||||
contact_count = cursor.fetchone()['count']
|
|
||||||
|
|
||||||
cursor.execute("SELECT COUNT(*) as count FROM contact_companies")
|
|
||||||
link_count = cursor.fetchone()['count']
|
|
||||||
|
|
||||||
logger.info(f"\n📊 Summary:")
|
|
||||||
logger.info(f" Customers: {customer_count}")
|
|
||||||
logger.info(f" Contacts: {contact_count}")
|
|
||||||
logger.info(f" Company-Contact Links: {link_count}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.rollback()
|
|
||||||
logger.error(f"❌ Import failed: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
logger.info("🚀 Starting data import...")
|
|
||||||
import_sample_data()
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
"""
|
|
||||||
Import vendors from OmniSync database
|
|
||||||
"""
|
|
||||||
|
|
||||||
import psycopg2
|
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
OMNISYNC_DB = '/omnisync_data/fakturering.db'
|
|
||||||
|
|
||||||
|
|
||||||
def get_postgres_connection():
|
|
||||||
"""Get PostgreSQL connection"""
|
|
||||||
database_url = os.getenv('DATABASE_URL', 'postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub')
|
|
||||||
return psycopg2.connect(database_url, cursor_factory=RealDictCursor)
|
|
||||||
|
|
||||||
|
|
||||||
def get_sqlite_connection():
|
|
||||||
"""Get SQLite connection to OmniSync database"""
|
|
||||||
return sqlite3.connect(OMNISYNC_DB)
|
|
||||||
|
|
||||||
|
|
||||||
def import_vendors():
|
|
||||||
"""Import vendors from OmniSync"""
|
|
||||||
sqlite_conn = get_sqlite_connection()
|
|
||||||
sqlite_cursor = sqlite_conn.cursor()
|
|
||||||
|
|
||||||
imported = 0
|
|
||||||
skipped = 0
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get vendors from OmniSync
|
|
||||||
sqlite_cursor.execute("""
|
|
||||||
SELECT
|
|
||||||
name, domain, email_pattern, category, priority, notes, created_at
|
|
||||||
FROM vendors
|
|
||||||
WHERE deleted_at IS NULL
|
|
||||||
ORDER BY name
|
|
||||||
""")
|
|
||||||
|
|
||||||
vendors = sqlite_cursor.fetchall()
|
|
||||||
logger.info(f"📥 Found {len(vendors)} active vendors in OmniSync")
|
|
||||||
|
|
||||||
for row in vendors:
|
|
||||||
name, domain, email_pattern, category, priority, notes, created_at = row
|
|
||||||
|
|
||||||
# Skip if no name
|
|
||||||
if not name or name.strip() == '':
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Individual connection per vendor
|
|
||||||
postgres_conn = get_postgres_connection()
|
|
||||||
postgres_cursor = postgres_conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
postgres_cursor.execute("""
|
|
||||||
INSERT INTO vendors (
|
|
||||||
name, domain, email_pattern, category, priority, notes, is_active, created_at
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
ON CONFLICT (cvr_number) DO NOTHING
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
name, domain, email_pattern, category or 'general',
|
|
||||||
priority or 50, notes, True, created_at
|
|
||||||
))
|
|
||||||
|
|
||||||
result = postgres_cursor.fetchone()
|
|
||||||
postgres_conn.commit()
|
|
||||||
|
|
||||||
if result:
|
|
||||||
imported += 1
|
|
||||||
if imported % 10 == 0:
|
|
||||||
logger.info(f" Imported {imported} vendors...")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = str(e)[:100]
|
|
||||||
if imported + skipped < 10:
|
|
||||||
logger.warning(f" ⚠️ Could not import '{name}': {error_msg}")
|
|
||||||
errors.append((name, error_msg))
|
|
||||||
skipped += 1
|
|
||||||
finally:
|
|
||||||
postgres_cursor.close()
|
|
||||||
postgres_conn.close()
|
|
||||||
|
|
||||||
logger.info(f"✅ Vendors: {imported} imported, {skipped} skipped")
|
|
||||||
if len(errors) > 10:
|
|
||||||
logger.info(f" (Suppressed {len(errors)-10} error messages)")
|
|
||||||
|
|
||||||
return imported
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Vendor import failed: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
sqlite_cursor.close()
|
|
||||||
sqlite_conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
logger.info("🚀 Starting vendor import from OmniSync...")
|
|
||||||
logger.info(f"📂 Source: {OMNISYNC_DB}")
|
|
||||||
|
|
||||||
vendor_count = import_vendors()
|
|
||||||
|
|
||||||
logger.info(f"\n🎉 Import completed!")
|
|
||||||
logger.info(f" Vendors: {vendor_count}")
|
|
||||||
@ -1,282 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Nordic Customers</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-body: #f8f9fa;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
--text-primary: #2c3e50;
|
|
||||||
--text-secondary: #6c757d;
|
|
||||||
--accent: #0f4c75;
|
|
||||||
--accent-light: #eef2f5;
|
|
||||||
--border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
background: var(--bg-card);
|
|
||||||
height: 100vh;
|
|
||||||
position: fixed;
|
|
||||||
width: 260px;
|
|
||||||
border-right: 1px solid #eee;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link i {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 260px;
|
|
||||||
padding: 2rem 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
padding-top: 1rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding-top: 1rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
padding: 0.6rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-search {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
border-radius: 50px;
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="sidebar">
|
|
||||||
<div class="d-flex align-items-center mb-5">
|
|
||||||
<div class="bg-primary rounded-circle p-2 me-2 d-flex align-items-center justify-content-center" style="width: 40px; height: 40px; background-color: var(--accent) !important;">
|
|
||||||
<i class="bi bi-hdd-network text-white"></i>
|
|
||||||
</div>
|
|
||||||
<h4 class="mb-0 fw-bold">BMC Hub</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<a class="nav-link" href="index.html"><i class="bi bi-grid"></i> Dashboard</a>
|
|
||||||
<a class="nav-link active" href="customers.html"><i class="bi bi-people"></i> Kunder</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-router"></i> Hardware</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-receipt"></i> Fakturering</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-gear"></i> Indstillinger</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Kunder</h2>
|
|
||||||
<p class="text-muted mb-0">Administrer dine kunder og deres services</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" class="header-search" placeholder="Søg efter kunde...">
|
|
||||||
<button class="btn btn-primary"><i class="bi bi-plus-lg"></i> Opret Kunde</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card p-4">
|
|
||||||
<div class="d-flex gap-2 mb-4">
|
|
||||||
<button class="btn btn-light active fw-medium">Alle Kunder</button>
|
|
||||||
<button class="btn btn-light text-muted">Aktive</button>
|
|
||||||
<button class="btn btn-light text-muted">Inaktive</button>
|
|
||||||
<button class="btn btn-light text-muted">VIP</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Virksomhed</th>
|
|
||||||
<th>Kontaktperson</th>
|
|
||||||
<th>CVR Nummer</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Hardware</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="avatar">A</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Advokatgruppen A/S</div>
|
|
||||||
<div class="small text-muted">København K</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Jens Jensen</div>
|
|
||||||
<div class="small text-muted">jens@advokat.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">12345678</td>
|
|
||||||
<td><span class="badge bg-success-subtle text-success rounded-pill px-3">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex gap-1">
|
|
||||||
<span class="badge bg-light text-dark border">Firewall</span>
|
|
||||||
<span class="badge bg-light text-dark border">Switch</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-light"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="avatar" style="background-color: #fff3cd; color: #856404;">B</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Byg & Bo ApS</div>
|
|
||||||
<div class="small text-muted">Aarhus C</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Mette Hansen</div>
|
|
||||||
<div class="small text-muted">mh@bygbo.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">87654321</td>
|
|
||||||
<td><span class="badge bg-success-subtle text-success rounded-pill px-3">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex gap-1">
|
|
||||||
<span class="badge bg-light text-dark border">Router</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-light"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="avatar" style="background-color: #f8d7da; color: #721c24;">C</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Cafe Møller</div>
|
|
||||||
<div class="small text-muted">Odense M</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Peter Møller</div>
|
|
||||||
<div class="small text-muted">pm@cafe.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">11223344</td>
|
|
||||||
<td><span class="badge bg-warning-subtle text-warning rounded-pill px-3">Afventer</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="text-muted small">-</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-light"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="avatar" style="background-color: #d1e7dd; color: #0f5132;">D</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Dansk Design Hus</div>
|
|
||||||
<div class="small text-muted">København Ø</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Lars Larsen</div>
|
|
||||||
<div class="small text-muted">ll@design.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">44332211</td>
|
|
||||||
<td><span class="badge bg-success-subtle text-success rounded-pill px-3">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex gap-1">
|
|
||||||
<span class="badge bg-light text-dark border">Firewall</span>
|
|
||||||
<span class="badge bg-light text-dark border">AP x4</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-light"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
|
||||||
<div class="text-muted small">Viser 1-4 af 124 kunder</div>
|
|
||||||
<nav>
|
|
||||||
<ul class="pagination pagination-sm mb-0">
|
|
||||||
<li class="page-item disabled"><a class="page-link" href="#">Forrige</a></li>
|
|
||||||
<li class="page-item active"><a class="page-link" href="#">1</a></li>
|
|
||||||
<li class="page-item"><a class="page-link" href="#">2</a></li>
|
|
||||||
<li class="page-item"><a class="page-link" href="#">3</a></li>
|
|
||||||
<li class="page-item"><a class="page-link" href="#">Næste</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,292 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Nordic Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-body: #f8f9fa;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
--text-primary: #2c3e50;
|
|
||||||
--text-secondary: #6c757d;
|
|
||||||
--accent: #0f4c75;
|
|
||||||
--accent-light: #eef2f5;
|
|
||||||
--border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
background: var(--bg-card);
|
|
||||||
height: 100vh;
|
|
||||||
position: fixed;
|
|
||||||
width: 260px;
|
|
||||||
border-right: 1px solid #eee;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background-color: var(--accent-light);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link i {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 260px;
|
|
||||||
padding: 2rem 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card h3 {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card p {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
padding: 0.6rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-search {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
border-radius: 50px;
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="sidebar">
|
|
||||||
<div class="d-flex align-items-center mb-5">
|
|
||||||
<div class="bg-primary rounded-circle p-2 me-2 d-flex align-items-center justify-content-center" style="width: 40px; height: 40px; background-color: var(--accent) !important;">
|
|
||||||
<i class="bi bi-hdd-network text-white"></i>
|
|
||||||
</div>
|
|
||||||
<h4 class="mb-0 fw-bold">BMC Hub</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<a class="nav-link active" href="index.html"><i class="bi bi-grid"></i> Dashboard</a>
|
|
||||||
<a class="nav-link" href="customers.html"><i class="bi bi-people"></i> Kunder</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-router"></i> Hardware</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-receipt"></i> Fakturering</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-gear"></i> Indstillinger</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Oversigt</h2>
|
|
||||||
<p class="text-muted mb-0">Velkommen tilbage, Christian</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" class="header-search" placeholder="Søg...">
|
|
||||||
<button class="btn btn-primary"><i class="bi bi-plus-lg"></i> Ny Opgave</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4 mb-5">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card stat-card h-100 p-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<p>Aktive Kunder</p>
|
|
||||||
<h3>124</h3>
|
|
||||||
</div>
|
|
||||||
<span class="badge bg-success-subtle text-success">+12%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card stat-card h-100 p-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<p>Hardware Enheder</p>
|
|
||||||
<h3>856</h3>
|
|
||||||
</div>
|
|
||||||
<span class="badge bg-primary-subtle text-primary">Online</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card stat-card h-100 p-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<p>Support Sager</p>
|
|
||||||
<h3>12</h3>
|
|
||||||
</div>
|
|
||||||
<span class="badge bg-warning-subtle text-warning">3 Haster</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card stat-card h-100 p-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<p>Omsætning (Mdr)</p>
|
|
||||||
<h3>450k</h3>
|
|
||||||
</div>
|
|
||||||
<span class="badge bg-success-subtle text-success">+5%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card p-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h5 class="fw-bold mb-0">Seneste Aktiviteter</h5>
|
|
||||||
<a href="#" class="text-decoration-none small">Se alle</a>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Kunde</th>
|
|
||||||
<th>Handling</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Dato</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">A</div>
|
|
||||||
<span class="fw-medium">Advokatgruppen A/S</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>Ny firewall konfigureret</td>
|
|
||||||
<td><span class="badge bg-success-subtle text-success rounded-pill">Fuldført</span></td>
|
|
||||||
<td class="text-muted">I dag, 10:23</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">B</div>
|
|
||||||
<span class="fw-medium">Byg & Bo ApS</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>Opdatering af licenser</td>
|
|
||||||
<td><span class="badge bg-warning-subtle text-warning rounded-pill">Afventer</span></td>
|
|
||||||
<td class="text-muted">I går, 14:45</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">C</div>
|
|
||||||
<span class="fw-medium">Cafe Møller</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>Netværksnedbrud</td>
|
|
||||||
<td><span class="badge bg-danger-subtle text-danger rounded-pill">Kritisk</span></td>
|
|
||||||
<td class="text-muted">I går, 09:12</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card p-4 h-100">
|
|
||||||
<h5 class="fw-bold mb-4">System Status</h5>
|
|
||||||
<div class="d-flex align-items-center mb-4">
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<div class="d-flex justify-content-between mb-1">
|
|
||||||
<span class="small fw-medium">Server Load</span>
|
|
||||||
<span class="small text-muted">24%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 6px;">
|
|
||||||
<div class="progress-bar bg-success" style="width: 24%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-items-center mb-4">
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<div class="d-flex justify-content-between mb-1">
|
|
||||||
<span class="small fw-medium">Database</span>
|
|
||||||
<span class="small text-muted">56%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 6px;">
|
|
||||||
<div class="progress-bar bg-primary" style="width: 56%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<div class="d-flex justify-content-between mb-1">
|
|
||||||
<span class="small fw-medium">Storage</span>
|
|
||||||
<span class="small text-muted">89%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 6px;">
|
|
||||||
<div class="progress-bar bg-warning" style="width: 89%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto pt-4">
|
|
||||||
<div class="alert alert-light border mb-0">
|
|
||||||
<div class="d-flex">
|
|
||||||
<i class="bi bi-info-circle text-primary me-2"></i>
|
|
||||||
<small class="text-muted">System backup kører i nat kl. 03:00</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,298 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-bs-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Dark Customers</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-body: #0f111a;
|
|
||||||
--bg-card: #1a1d2d;
|
|
||||||
--bg-sidebar: #141622;
|
|
||||||
--accent: #6c5ce7;
|
|
||||||
--accent-hover: #5b4cc4;
|
|
||||||
--text-primary: #ffffff;
|
|
||||||
--text-secondary: #a0a0a0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
background: var(--bg-sidebar);
|
|
||||||
height: 100vh;
|
|
||||||
position: fixed;
|
|
||||||
width: 250px;
|
|
||||||
border-right: 1px solid rgba(255,255,255,0.05);
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background-color: rgba(108, 92, 231, 0.15);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 250px;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
border: 1px solid rgba(255,255,255,0.05);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--accent);
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: var(--accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
color: white;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input:focus {
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--accent);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
--bs-table-bg: transparent;
|
|
||||||
--bs-table-color: var(--text-secondary);
|
|
||||||
--bs-table-border-color: rgba(255,255,255,0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 1rem 0.5rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tr:hover td {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customer-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.4rem 1rem;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn:hover, .filter-btn.active {
|
|
||||||
background: rgba(108, 92, 231, 0.15);
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="sidebar">
|
|
||||||
<div class="d-flex align-items-center mb-5 px-2">
|
|
||||||
<div class="me-2 text-primary">
|
|
||||||
<i class="bi bi-hexagon-fill fs-4" style="color: var(--accent);"></i>
|
|
||||||
</div>
|
|
||||||
<h5 class="mb-0 fw-bold text-white tracking-wide">BMC HUB</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<small class="text-uppercase text-muted mb-2 px-3" style="font-size: 0.7rem; letter-spacing: 1px;">Menu</small>
|
|
||||||
<a class="nav-link" href="index.html"><i class="bi bi-speedometer2 me-2"></i> Dashboard</a>
|
|
||||||
<a class="nav-link active" href="customers.html"><i class="bi bi-people me-2"></i> Kunder</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-box-seam me-2"></i> Hardware</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-credit-card me-2"></i> Fakturering</a>
|
|
||||||
|
|
||||||
<small class="text-uppercase text-muted mb-2 mt-4 px-3" style="font-size: 0.7rem; letter-spacing: 1px;">System</small>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-shield-check me-2"></i> Sikkerhed</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-gear me-2"></i> Indstillinger</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-white mb-1">Kundeoversigt</h3>
|
|
||||||
<p class="text-secondary mb-0">Administrer kunder og abonnementer</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" class="search-input" placeholder="Søg efter kunde...">
|
|
||||||
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Opret Kunde</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card p-4">
|
|
||||||
<div class="d-flex gap-2 mb-4">
|
|
||||||
<button class="filter-btn active">Alle Kunder</button>
|
|
||||||
<button class="filter-btn">Aktive</button>
|
|
||||||
<button class="filter-btn">Inaktive</button>
|
|
||||||
<button class="filter-btn">VIP</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="text-secondary fw-normal ps-0">Virksomhed</th>
|
|
||||||
<th class="text-secondary fw-normal">Kontakt</th>
|
|
||||||
<th class="text-secondary fw-normal">CVR</th>
|
|
||||||
<th class="text-secondary fw-normal">Status</th>
|
|
||||||
<th class="text-secondary fw-normal">Udstyr</th>
|
|
||||||
<th class="text-secondary fw-normal text-end pe-0">Handling</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-0">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="customer-avatar me-3">A</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-white fw-medium">Advokatgruppen A/S</div>
|
|
||||||
<div class="small text-secondary">København K</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="text-white">Jens Jensen</div>
|
|
||||||
<div class="small text-secondary">jens@advokat.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-secondary">12345678</td>
|
|
||||||
<td><span class="badge bg-success-subtle text-success border border-success-subtle">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-dark border border-secondary text-secondary">Firewall</span>
|
|
||||||
<span class="badge bg-dark border border-secondary text-secondary">Switch</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end pe-0">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary border-0"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-0">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="customer-avatar me-3" style="color: #ffeaa7;">B</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-white fw-medium">Byg & Bo ApS</div>
|
|
||||||
<div class="small text-secondary">Aarhus C</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="text-white">Mette Hansen</div>
|
|
||||||
<div class="small text-secondary">mh@bygbo.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-secondary">87654321</td>
|
|
||||||
<td><span class="badge bg-success-subtle text-success border border-success-subtle">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-dark border border-secondary text-secondary">Router</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end pe-0">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary border-0"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-0">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="customer-avatar me-3" style="color: #ff7675;">C</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-white fw-medium">Cafe Møller</div>
|
|
||||||
<div class="small text-secondary">Odense M</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="text-white">Peter Møller</div>
|
|
||||||
<div class="small text-secondary">pm@cafe.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-secondary">11223344</td>
|
|
||||||
<td><span class="badge bg-warning-subtle text-warning border border-warning-subtle">Afventer</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="text-secondary small">-</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end pe-0">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary border-0"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-0">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="customer-avatar me-3" style="color: #55efc4;">D</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-white fw-medium">Dansk Design Hus</div>
|
|
||||||
<div class="small text-secondary">København Ø</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="text-white">Lars Larsen</div>
|
|
||||||
<div class="small text-secondary">ll@design.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-secondary">44332211</td>
|
|
||||||
<td><span class="badge bg-success-subtle text-success border border-success-subtle">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-dark border border-secondary text-secondary">Firewall</span>
|
|
||||||
<span class="badge bg-dark border border-secondary text-secondary">+4</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end pe-0">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary border-0"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mt-4 border-top border-secondary border-opacity-10 pt-4">
|
|
||||||
<div class="text-secondary small">Viser 1-4 af 124 kunder</div>
|
|
||||||
<nav>
|
|
||||||
<ul class="pagination pagination-sm mb-0">
|
|
||||||
<li class="page-item disabled"><a class="page-link bg-transparent border-secondary border-opacity-25 text-secondary" href="#">Forrige</a></li>
|
|
||||||
<li class="page-item active"><a class="page-link border-accent bg-accent" style="background-color: var(--accent); border-color: var(--accent);" href="#">1</a></li>
|
|
||||||
<li class="page-item"><a class="page-link bg-transparent border-secondary border-opacity-25 text-secondary" href="#">2</a></li>
|
|
||||||
<li class="page-item"><a class="page-link bg-transparent border-secondary border-opacity-25 text-secondary" href="#">3</a></li>
|
|
||||||
<li class="page-item"><a class="page-link bg-transparent border-secondary border-opacity-25 text-secondary" href="#">Næste</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,267 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-bs-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Dark Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-body: #0f111a;
|
|
||||||
--bg-card: #1a1d2d;
|
|
||||||
--bg-sidebar: #141622;
|
|
||||||
--accent: #6c5ce7;
|
|
||||||
--accent-hover: #5b4cc4;
|
|
||||||
--text-primary: #ffffff;
|
|
||||||
--text-secondary: #a0a0a0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
background: var(--bg-sidebar);
|
|
||||||
height: 100vh;
|
|
||||||
position: fixed;
|
|
||||||
width: 250px;
|
|
||||||
border-right: 1px solid rgba(255,255,255,0.05);
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background-color: rgba(108, 92, 231, 0.15);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 250px;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
border: 1px solid rgba(255,255,255,0.05);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: 300;
|
|
||||||
color: white;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--accent);
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: var(--accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
color: white;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input:focus {
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--accent);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
--bs-table-bg: transparent;
|
|
||||||
--bs-table-color: var(--text-secondary);
|
|
||||||
--bs-table-border-color: rgba(255,255,255,0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 1rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tr:hover td {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="sidebar">
|
|
||||||
<div class="d-flex align-items-center mb-5 px-2">
|
|
||||||
<div class="me-2 text-primary">
|
|
||||||
<i class="bi bi-hexagon-fill fs-4" style="color: var(--accent);"></i>
|
|
||||||
</div>
|
|
||||||
<h5 class="mb-0 fw-bold text-white tracking-wide">BMC HUB</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<small class="text-uppercase text-muted mb-2 px-3" style="font-size: 0.7rem; letter-spacing: 1px;">Menu</small>
|
|
||||||
<a class="nav-link active" href="index.html"><i class="bi bi-speedometer2 me-2"></i> Dashboard</a>
|
|
||||||
<a class="nav-link" href="customers.html"><i class="bi bi-people me-2"></i> Kunder</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-box-seam me-2"></i> Hardware</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-credit-card me-2"></i> Fakturering</a>
|
|
||||||
|
|
||||||
<small class="text-uppercase text-muted mb-2 mt-4 px-3" style="font-size: 0.7rem; letter-spacing: 1px;">System</small>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-shield-check me-2"></i> Sikkerhed</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-gear me-2"></i> Indstillinger</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-white mb-1">Dashboard</h3>
|
|
||||||
<p class="text-secondary mb-0">System overblik og status</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" class="search-input" placeholder="Søg i systemet...">
|
|
||||||
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Ny Opgave</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4 mb-5">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-4 h-100">
|
|
||||||
<div class="stat-label mb-2">Aktive Kunder</div>
|
|
||||||
<div class="stat-value mb-2">124</div>
|
|
||||||
<div class="text-success small"><i class="bi bi-arrow-up-short"></i> 12% denne måned</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-4 h-100">
|
|
||||||
<div class="stat-label mb-2">Hardware</div>
|
|
||||||
<div class="stat-value mb-2">856</div>
|
|
||||||
<div class="text-secondary small">Enheder online</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-4 h-100">
|
|
||||||
<div class="stat-label mb-2">Support</div>
|
|
||||||
<div class="stat-value mb-2" style="color: #ff7675;">12</div>
|
|
||||||
<div class="text-secondary small">Åbne sager</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-4 h-100">
|
|
||||||
<div class="stat-label mb-2">Omsætning</div>
|
|
||||||
<div class="stat-value mb-2">450k</div>
|
|
||||||
<div class="text-success small">DKK denne måned</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card p-4">
|
|
||||||
<h5 class="text-white mb-4">Seneste Hændelser</h5>
|
|
||||||
<table class="table align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="text-secondary fw-normal ps-0">Hændelse</th>
|
|
||||||
<th class="text-secondary fw-normal">Kunde</th>
|
|
||||||
<th class="text-secondary fw-normal">Tid</th>
|
|
||||||
<th class="text-secondary fw-normal text-end pe-0">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-0 text-white">Firewall konfiguration ændret</td>
|
|
||||||
<td>Advokatgruppen A/S</td>
|
|
||||||
<td>10:23</td>
|
|
||||||
<td class="text-end pe-0"><span class="badge bg-success-subtle text-success border border-success-subtle">Fuldført</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-0 text-white">Licens fornyelse</td>
|
|
||||||
<td>Byg & Bo ApS</td>
|
|
||||||
<td>I går</td>
|
|
||||||
<td class="text-end pe-0"><span class="badge bg-warning-subtle text-warning border border-warning-subtle">Afventer</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-0 text-white">VPN forbindelse tabt</td>
|
|
||||||
<td>Cafe Møller</td>
|
|
||||||
<td>I går</td>
|
|
||||||
<td class="text-end pe-0"><span class="badge bg-danger-subtle text-danger border border-danger-subtle">Fejl</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-0 text-white">Ny bruger oprettet</td>
|
|
||||||
<td>Dansk Design Hus</td>
|
|
||||||
<td>2 dage siden</td>
|
|
||||||
<td class="text-end pe-0"><span class="badge bg-success-subtle text-success border border-success-subtle">Fuldført</span></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card p-4 h-100">
|
|
||||||
<h5 class="text-white mb-4">Server Status</h5>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<span class="text-secondary">CPU Usage</span>
|
|
||||||
<span class="text-white">24%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress bg-dark" style="height: 4px;">
|
|
||||||
<div class="progress-bar" style="width: 24%; background-color: var(--accent);"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<span class="text-secondary">Memory</span>
|
|
||||||
<span class="text-white">56%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress bg-dark" style="height: 4px;">
|
|
||||||
<div class="progress-bar bg-info" style="width: 56%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<span class="text-secondary">Storage</span>
|
|
||||||
<span class="text-white">89%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress bg-dark" style="height: 4px;">
|
|
||||||
<div class="progress-bar bg-warning" style="width: 89%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto p-3 rounded" style="background: rgba(108, 92, 231, 0.1); border: 1px solid rgba(108, 92, 231, 0.2);">
|
|
||||||
<div class="d-flex">
|
|
||||||
<i class="bi bi-lightning-charge-fill me-2" style="color: var(--accent);"></i>
|
|
||||||
<small class="text-white">Systemet kører optimalt. Ingen kritiske fejl fundet.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,285 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Swiss Customers</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap');
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg-body: #ffffff;
|
|
||||||
--text-primary: #000000;
|
|
||||||
--text-secondary: #666666;
|
|
||||||
--accent: #ff3b30; /* Swiss Red */
|
|
||||||
--grid-line: #e5e5e5;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
height: 100vh;
|
|
||||||
position: fixed;
|
|
||||||
width: 280px;
|
|
||||||
border-right: 2px solid var(--text-primary);
|
|
||||||
padding: 0;
|
|
||||||
background: white;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-section {
|
|
||||||
padding: 2rem;
|
|
||||||
border-bottom: 2px solid var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-primary);
|
|
||||||
padding: 1.2rem 2rem;
|
|
||||||
border-bottom: 1px solid var(--grid-line);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
border-radius: 0;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background-color: var(--text-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 280px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-bar {
|
|
||||||
border-bottom: 2px solid var(--text-primary);
|
|
||||||
padding: 2rem 3rem;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-grid {
|
|
||||||
padding: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--text-primary);
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
border: 2px solid var(--text-primary);
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
background: var(--text-primary);
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 1rem;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 1.2rem 1rem;
|
|
||||||
border-bottom: 1px solid var(--grid-line);
|
|
||||||
font-weight: 500;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
border: 1px solid var(--text-primary);
|
|
||||||
padding: 0.2rem 0.8rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.active {
|
|
||||||
background: var(--text-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-bar {
|
|
||||||
border: 2px solid var(--text-primary);
|
|
||||||
border-bottom: none;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid var(--text-primary);
|
|
||||||
padding: 0.5rem 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn.active {
|
|
||||||
background: var(--text-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5 {
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="sidebar">
|
|
||||||
<div class="brand-section">
|
|
||||||
<h2 class="mb-0">BMC<span style="color: var(--accent)">.</span>HUB</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<a class="nav-link" href="index.html">Dashboard</a>
|
|
||||||
<a class="nav-link active" href="customers.html">Kunder</a>
|
|
||||||
<a class="nav-link" href="#">Hardware</a>
|
|
||||||
<a class="nav-link" href="#">Fakturering</a>
|
|
||||||
<a class="nav-link" href="#">Indstillinger</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="top-bar d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h1 class="mb-0">KUNDER</h1>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" class="form-control rounded-0 border-dark border-2" placeholder="SØG KUNDE..." style="width: 300px;">
|
|
||||||
<button class="btn btn-primary">OPRET KUNDE</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content-grid">
|
|
||||||
<div class="filter-bar d-flex align-items-center">
|
|
||||||
<span class="fw-bold me-3">FILTER:</span>
|
|
||||||
<button class="filter-btn active">ALLE</button>
|
|
||||||
<button class="filter-btn">AKTIVE</button>
|
|
||||||
<button class="filter-btn">INAKTIVE</button>
|
|
||||||
<button class="filter-btn">VIP</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Virksomhed</th>
|
|
||||||
<th>Kontaktperson</th>
|
|
||||||
<th>CVR</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Hardware</th>
|
|
||||||
<th class="text-end">Handling</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="fw-bold">Advokatgruppen A/S</div>
|
|
||||||
<div class="small text-secondary">København K</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Jens Jensen</div>
|
|
||||||
<div class="small text-secondary">jens@advokat.dk</div>
|
|
||||||
</td>
|
|
||||||
<td>12345678</td>
|
|
||||||
<td><span class="status-badge active">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="fw-bold small">FIREWALL, SWITCH</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-outline-dark rounded-0 fw-bold">REDIGER</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="fw-bold">Byg & Bo ApS</div>
|
|
||||||
<div class="small text-secondary">Aarhus C</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Mette Hansen</div>
|
|
||||||
<div class="small text-secondary">mh@bygbo.dk</div>
|
|
||||||
</td>
|
|
||||||
<td>87654321</td>
|
|
||||||
<td><span class="status-badge active">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="fw-bold small">ROUTER</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-outline-dark rounded-0 fw-bold">REDIGER</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="fw-bold">Cafe Møller</div>
|
|
||||||
<div class="small text-secondary">Odense M</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Peter Møller</div>
|
|
||||||
<div class="small text-secondary">pm@cafe.dk</div>
|
|
||||||
</td>
|
|
||||||
<td>11223344</td>
|
|
||||||
<td><span class="status-badge">Afventer</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="text-secondary">-</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-outline-dark rounded-0 fw-bold">REDIGER</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="fw-bold">Dansk Design Hus</div>
|
|
||||||
<div class="small text-secondary">København Ø</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Lars Larsen</div>
|
|
||||||
<div class="small text-secondary">ll@design.dk</div>
|
|
||||||
</td>
|
|
||||||
<td>44332211</td>
|
|
||||||
<td><span class="status-badge active">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="fw-bold small">FIREWALL, AP x4</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-outline-dark rounded-0 fw-bold">REDIGER</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
|
||||||
<div class="fw-bold small">VISER 1-4 AF 124 KUNDER</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button class="btn btn-outline-dark rounded-0 btn-sm fw-bold" disabled>FORRIGE</button>
|
|
||||||
<button class="btn btn-dark rounded-0 btn-sm fw-bold">1</button>
|
|
||||||
<button class="btn btn-outline-dark rounded-0 btn-sm fw-bold">2</button>
|
|
||||||
<button class="btn btn-outline-dark rounded-0 btn-sm fw-bold">3</button>
|
|
||||||
<button class="btn btn-outline-dark rounded-0 btn-sm fw-bold">NÆSTE</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,278 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Swiss Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap');
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg-body: #ffffff;
|
|
||||||
--text-primary: #000000;
|
|
||||||
--text-secondary: #666666;
|
|
||||||
--accent: #ff3b30; /* Swiss Red */
|
|
||||||
--grid-line: #e5e5e5;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
height: 100vh;
|
|
||||||
position: fixed;
|
|
||||||
width: 280px;
|
|
||||||
border-right: 2px solid var(--text-primary);
|
|
||||||
padding: 0;
|
|
||||||
background: white;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-section {
|
|
||||||
padding: 2rem;
|
|
||||||
border-bottom: 2px solid var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-primary);
|
|
||||||
padding: 1.2rem 2rem;
|
|
||||||
border-bottom: 1px solid var(--grid-line);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
border-radius: 0;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
background-color: var(--text-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 280px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-bar {
|
|
||||||
border-bottom: 2px solid var(--text-primary);
|
|
||||||
padding: 2rem 3rem;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-grid {
|
|
||||||
padding: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
border: 2px solid var(--text-primary);
|
|
||||||
padding: 2rem;
|
|
||||||
height: 100%;
|
|
||||||
background: white;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:hover {
|
|
||||||
box-shadow: 8px 8px 0px var(--text-primary);
|
|
||||||
transform: translate(-4px, -4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-number {
|
|
||||||
font-size: 3.5rem;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
letter-spacing: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--text-primary);
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
border: 2px solid var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
background: var(--text-primary);
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 1rem;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 1.2rem 1rem;
|
|
||||||
border-bottom: 1px solid var(--grid-line);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background: var(--text-primary);
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.success { background: #2ecc71; }
|
|
||||||
.status-dot.warning { background: #f1c40f; }
|
|
||||||
.status-dot.danger { background: #e74c3c; }
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5 {
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="sidebar">
|
|
||||||
<div class="brand-section">
|
|
||||||
<h2 class="mb-0">BMC<span style="color: var(--accent)">.</span>HUB</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<a class="nav-link active" href="index.html">Dashboard</a>
|
|
||||||
<a class="nav-link" href="customers.html">Kunder</a>
|
|
||||||
<a class="nav-link" href="#">Hardware</a>
|
|
||||||
<a class="nav-link" href="#">Fakturering</a>
|
|
||||||
<a class="nav-link" href="#">Indstillinger</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="p-4 mt-auto">
|
|
||||||
<div class="p-3 bg-light border border-dark">
|
|
||||||
<small class="fw-bold d-block mb-1">SYSTEM STATUS</small>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="status-dot success"></div>
|
|
||||||
<span class="fw-bold">ONLINE</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="top-bar d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<h1 class="mb-0">OVERSIGT</h1>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<button class="btn btn-outline-dark rounded-0 fw-bold px-4">SØG</button>
|
|
||||||
<button class="btn btn-primary">NY OPGAVE</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content-grid">
|
|
||||||
<div class="row g-4 mb-5">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-number">124</div>
|
|
||||||
<div class="stat-label">Aktive Kunder</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-number">856</div>
|
|
||||||
<div class="stat-label">Hardware Enheder</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-number" style="color: var(--accent)">12</div>
|
|
||||||
<div class="stat-label">Support Sager</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-number">450k</div>
|
|
||||||
<div class="stat-label">Månedlig Omsætning</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-5">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<h4 class="mb-4">SENESTE AKTIVITETER</h4>
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Kunde</th>
|
|
||||||
<th>Handling</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Tid</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>Advokatgruppen A/S</td>
|
|
||||||
<td>Firewall konfiguration</td>
|
|
||||||
<td><div class="status-dot success"></div> Fuldført</td>
|
|
||||||
<td>10:23</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Byg & Bo ApS</td>
|
|
||||||
<td>Licens fornyelse</td>
|
|
||||||
<td><div class="status-dot warning"></div> Afventer</td>
|
|
||||||
<td>I går</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Cafe Møller</td>
|
|
||||||
<td>Netværksnedbrud</td>
|
|
||||||
<td><div class="status-dot danger"></div> Kritisk</td>
|
|
||||||
<td>I går</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Dansk Design Hus</td>
|
|
||||||
<td>Ny bruger oprettet</td>
|
|
||||||
<td><div class="status-dot success"></div> Fuldført</td>
|
|
||||||
<td>2 dage siden</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<h4 class="mb-4">HURTIG ADGANG</h4>
|
|
||||||
<div class="d-grid gap-3">
|
|
||||||
<button class="btn btn-outline-dark rounded-0 text-start p-3 fw-bold">
|
|
||||||
<i class="bi bi-plus-lg me-2"></i> OPRET NY KUNDE
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline-dark rounded-0 text-start p-3 fw-bold">
|
|
||||||
<i class="bi bi-router me-2"></i> TILFØJ HARDWARE
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline-dark rounded-0 text-start p-3 fw-bold">
|
|
||||||
<i class="bi bi-file-text me-2"></i> OPRET FAKTURA
|
|
||||||
</button>
|
|
||||||
<div class="p-4 bg-light border border-dark mt-3">
|
|
||||||
<h5 class="mb-3">NOTER</h5>
|
|
||||||
<p class="mb-0 small fw-medium text-secondary">Husk at opdatere firewall regler for kunde #124 inden fredag.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,306 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Soft Customers</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap');
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg-body: #f0f2f5;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
--text-primary: #4a5568;
|
|
||||||
--text-secondary: #a0aec0;
|
|
||||||
--accent: #667eea;
|
|
||||||
--accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
--shadow-soft: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.025);
|
|
||||||
--border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: 'Nunito', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
background: var(--bg-card);
|
|
||||||
height: 95vh;
|
|
||||||
position: fixed;
|
|
||||||
width: 260px;
|
|
||||||
margin: 2.5vh;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-primary);
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-radius: 15px;
|
|
||||||
margin-bottom: 0.8rem;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover {
|
|
||||||
background-color: #f7fafc;
|
|
||||||
color: var(--accent);
|
|
||||||
transform: translateX(5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link.active {
|
|
||||||
background: var(--accent-gradient);
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 300px;
|
|
||||||
padding: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--accent-gradient);
|
|
||||||
border: none;
|
|
||||||
padding: 0.8rem 2rem;
|
|
||||||
border-radius: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.3);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 15px rgba(118, 75, 162, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar {
|
|
||||||
background: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.8rem 1.5rem;
|
|
||||||
border-radius: 15px;
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: none;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
border-bottom: 1px solid #f0f2f5;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-soft {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-pill {
|
|
||||||
background: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 1.5rem;
|
|
||||||
border-radius: 50px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-pill:hover, .filter-pill.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="sidebar">
|
|
||||||
<div class="d-flex align-items-center mb-5 px-3">
|
|
||||||
<div class="bg-white p-2 rounded-3 shadow-sm me-3">
|
|
||||||
<i class="bi bi-cloud-check-fill fs-4" style="color: #667eea;"></i>
|
|
||||||
</div>
|
|
||||||
<h4 class="mb-0 fw-bold">BMC Hub</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<a class="nav-link" href="index.html"><i class="bi bi-grid-fill me-3"></i> Dashboard</a>
|
|
||||||
<a class="nav-link active" href="customers.html"><i class="bi bi-people-fill me-3"></i> Kunder</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-router-fill me-3"></i> Hardware</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-receipt me-3"></i> Fakturering</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-gear-fill me-3"></i> Indstillinger</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Kunder</h2>
|
|
||||||
<p class="text-secondary mb-0">Administrer dine kunder og deres services</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" class="search-bar" placeholder="Søg efter kunde...">
|
|
||||||
<button class="btn btn-primary"><i class="bi bi-plus-lg"></i> Opret Kunde</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex gap-3 mb-4">
|
|
||||||
<button class="filter-pill active">Alle Kunder</button>
|
|
||||||
<button class="filter-pill">Aktive</button>
|
|
||||||
<button class="filter-pill">Inaktive</button>
|
|
||||||
<button class="filter-pill">VIP</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card p-4">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Virksomhed</th>
|
|
||||||
<th>Kontaktperson</th>
|
|
||||||
<th>CVR Nummer</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Hardware</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-3 bg-light p-2 me-3 text-primary">
|
|
||||||
<i class="bi bi-building"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Advokatgruppen A/S</div>
|
|
||||||
<div class="small text-secondary">København K</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-bold">Jens Jensen</div>
|
|
||||||
<div class="small text-secondary">jens@advokat.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-secondary">12345678</td>
|
|
||||||
<td><span class="badge bg-success-subtle text-success badge-soft">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark border">Firewall</span>
|
|
||||||
<span class="badge bg-light text-dark border">Switch</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-light rounded-circle"><i class="bi bi-three-dots"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-3 bg-light p-2 me-3 text-warning">
|
|
||||||
<i class="bi bi-building"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Byg & Bo ApS</div>
|
|
||||||
<div class="small text-secondary">Aarhus C</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-bold">Mette Hansen</div>
|
|
||||||
<div class="small text-secondary">mh@bygbo.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-secondary">87654321</td>
|
|
||||||
<td><span class="badge bg-success-subtle text-success badge-soft">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark border">Router</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-light rounded-circle"><i class="bi bi-three-dots"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-3 bg-light p-2 me-3 text-danger">
|
|
||||||
<i class="bi bi-building"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Cafe Møller</div>
|
|
||||||
<div class="small text-secondary">Odense M</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-bold">Peter Møller</div>
|
|
||||||
<div class="small text-secondary">pm@cafe.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-secondary">11223344</td>
|
|
||||||
<td><span class="badge bg-warning-subtle text-warning badge-soft">Afventer</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="text-secondary">-</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-light rounded-circle"><i class="bi bi-three-dots"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-3 bg-light p-2 me-3 text-success">
|
|
||||||
<i class="bi bi-building"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Dansk Design Hus</div>
|
|
||||||
<div class="small text-secondary">København Ø</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-bold">Lars Larsen</div>
|
|
||||||
<div class="small text-secondary">ll@design.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-secondary">44332211</td>
|
|
||||||
<td><span class="badge bg-success-subtle text-success badge-soft">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark border">Firewall</span>
|
|
||||||
<span class="badge bg-light text-dark border">AP x4</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-light rounded-circle"><i class="bi bi-three-dots"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-center mt-4">
|
|
||||||
<nav>
|
|
||||||
<ul class="pagination">
|
|
||||||
<li class="page-item disabled"><a class="page-link border-0 rounded-start-pill" href="#">Forrige</a></li>
|
|
||||||
<li class="page-item active"><a class="page-link border-0 bg-primary" href="#">1</a></li>
|
|
||||||
<li class="page-item"><a class="page-link border-0" href="#">2</a></li>
|
|
||||||
<li class="page-item"><a class="page-link border-0" href="#">3</a></li>
|
|
||||||
<li class="page-item"><a class="page-link border-0 rounded-end-pill" href="#">Næste</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,321 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Soft Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap');
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg-body: #f0f2f5;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
--text-primary: #4a5568;
|
|
||||||
--text-secondary: #a0aec0;
|
|
||||||
--accent: #667eea;
|
|
||||||
--accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
--shadow-soft: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.025);
|
|
||||||
--border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: 'Nunito', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
background: var(--bg-card);
|
|
||||||
height: 95vh;
|
|
||||||
position: fixed;
|
|
||||||
width: 260px;
|
|
||||||
margin: 2.5vh;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-primary);
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-radius: 15px;
|
|
||||||
margin-bottom: 0.8rem;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover {
|
|
||||||
background-color: #f7fafc;
|
|
||||||
color: var(--accent);
|
|
||||||
transform: translateX(5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link.active {
|
|
||||||
background: var(--accent-gradient);
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 300px;
|
|
||||||
padding: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border-radius: 15px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--accent-gradient);
|
|
||||||
border: none;
|
|
||||||
padding: 0.8rem 2rem;
|
|
||||||
border-radius: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.3);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 15px rgba(118, 75, 162, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar {
|
|
||||||
background: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.8rem 1.5rem;
|
|
||||||
border-radius: 15px;
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: none;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
border-bottom: 1px solid #f0f2f5;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-soft {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="sidebar">
|
|
||||||
<div class="d-flex align-items-center mb-5 px-3">
|
|
||||||
<div class="bg-white p-2 rounded-3 shadow-sm me-3">
|
|
||||||
<i class="bi bi-cloud-check-fill fs-4" style="color: #667eea;"></i>
|
|
||||||
</div>
|
|
||||||
<h4 class="mb-0 fw-bold">BMC Hub</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<a class="nav-link active" href="index.html"><i class="bi bi-grid-fill me-3"></i> Dashboard</a>
|
|
||||||
<a class="nav-link" href="customers.html"><i class="bi bi-people-fill me-3"></i> Kunder</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-router-fill me-3"></i> Hardware</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-receipt me-3"></i> Fakturering</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-gear-fill me-3"></i> Indstillinger</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="mt-auto px-3">
|
|
||||||
<div class="card bg-light p-3 border-0">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-white p-1 me-2">
|
|
||||||
<img src="https://ui-avatars.com/api/?name=Christian+Thomas&background=667eea&color=fff" class="rounded-circle" width="32">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<small class="d-block fw-bold">Christian</small>
|
|
||||||
<small class="text-muted" style="font-size: 0.7rem;">Admin</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Godmorgen, Christian! 👋</h2>
|
|
||||||
<p class="text-secondary mb-0">Her er dit overblik for i dag</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" class="search-bar" placeholder="Søg...">
|
|
||||||
<button class="btn btn-primary"><i class="bi bi-plus-lg"></i> Ny Opgave</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4 mb-5">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-4 h-100">
|
|
||||||
<div class="stat-icon bg-primary-subtle text-primary">
|
|
||||||
<i class="bi bi-people"></i>
|
|
||||||
</div>
|
|
||||||
<h3 class="fw-bold mb-1">124</h3>
|
|
||||||
<p class="text-secondary mb-0">Aktive Kunder</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-4 h-100">
|
|
||||||
<div class="stat-icon bg-success-subtle text-success">
|
|
||||||
<i class="bi bi-hdd-network"></i>
|
|
||||||
</div>
|
|
||||||
<h3 class="fw-bold mb-1">856</h3>
|
|
||||||
<p class="text-secondary mb-0">Hardware Enheder</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-4 h-100">
|
|
||||||
<div class="stat-icon bg-warning-subtle text-warning">
|
|
||||||
<i class="bi bi-ticket-perforated"></i>
|
|
||||||
</div>
|
|
||||||
<h3 class="fw-bold mb-1">12</h3>
|
|
||||||
<p class="text-secondary mb-0">Support Sager</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card p-4 h-100">
|
|
||||||
<div class="stat-icon bg-info-subtle text-info">
|
|
||||||
<i class="bi bi-graph-up"></i>
|
|
||||||
</div>
|
|
||||||
<h3 class="fw-bold mb-1">450k</h3>
|
|
||||||
<p class="text-secondary mb-0">Månedlig Omsætning</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card p-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<h5 class="fw-bold">Seneste Aktiviteter</h5>
|
|
||||||
<button class="btn btn-light btn-sm rounded-pill px-3">Se alle</button>
|
|
||||||
</div>
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Kunde</th>
|
|
||||||
<th>Handling</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Tid</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-3 bg-light p-2 me-3">
|
|
||||||
<i class="bi bi-building"></i>
|
|
||||||
</div>
|
|
||||||
<span class="fw-bold">Advokatgruppen A/S</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>Firewall konfiguration</td>
|
|
||||||
<td><span class="badge bg-success-subtle text-success badge-soft">Fuldført</span></td>
|
|
||||||
<td class="text-secondary">10:23</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-3 bg-light p-2 me-3">
|
|
||||||
<i class="bi bi-building"></i>
|
|
||||||
</div>
|
|
||||||
<span class="fw-bold">Byg & Bo ApS</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>Licens fornyelse</td>
|
|
||||||
<td><span class="badge bg-warning-subtle text-warning badge-soft">Afventer</span></td>
|
|
||||||
<td class="text-secondary">I går</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-3 bg-light p-2 me-3">
|
|
||||||
<i class="bi bi-building"></i>
|
|
||||||
</div>
|
|
||||||
<span class="fw-bold">Cafe Møller</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>Netværksnedbrud</td>
|
|
||||||
<td><span class="badge bg-danger-subtle text-danger badge-soft">Kritisk</span></td>
|
|
||||||
<td class="text-secondary">I går</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card p-4 h-100 bg-primary text-white" style="background: var(--accent-gradient);">
|
|
||||||
<h5 class="fw-bold mb-4">System Status</h5>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<span class="opacity-75">CPU Usage</span>
|
|
||||||
<span class="fw-bold">24%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress bg-white bg-opacity-25" style="height: 6px;">
|
|
||||||
<div class="progress-bar bg-white" style="width: 24%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<span class="opacity-75">Memory</span>
|
|
||||||
<span class="fw-bold">56%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress bg-white bg-opacity-25" style="height: 6px;">
|
|
||||||
<div class="progress-bar bg-white" style="width: 56%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto">
|
|
||||||
<div class="bg-white bg-opacity-10 p-3 rounded-3 backdrop-blur">
|
|
||||||
<div class="d-flex">
|
|
||||||
<i class="bi bi-check-circle-fill me-2"></i>
|
|
||||||
<small>Alle systemer kører normalt. Ingen nedetid registreret de sidste 24 timer.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Compact Customers</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-body: #e9ecef;
|
|
||||||
--bg-sidebar: #212529;
|
|
||||||
--accent: #0d6efd;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
background: var(--bg-sidebar);
|
|
||||||
height: 100vh;
|
|
||||||
position: fixed;
|
|
||||||
width: 220px;
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: #adb5bd;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
color: white;
|
|
||||||
background-color: rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 220px;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-bottom: 1px solid #dee2e6;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="sidebar">
|
|
||||||
<div class="px-3 mb-3">
|
|
||||||
<h6 class="text-white fw-bold mb-0">BMC HUB <span class="badge bg-primary ms-1">v1.0</span></h6>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<a class="nav-link" href="index.html"><i class="bi bi-speedometer2 me-2"></i> Dashboard</a>
|
|
||||||
<a class="nav-link active" href="customers.html"><i class="bi bi-people me-2"></i> Kunder</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-hdd-network me-2"></i> Hardware</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-receipt me-2"></i> Fakturering</a>
|
|
||||||
<div class="border-top border-secondary my-2 mx-3"></div>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-gear me-2"></i> Indstillinger</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-journal-text me-2"></i> Logs</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 bg-white p-2 border rounded">
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<h5 class="mb-0 fw-bold">Kundeoversigt</h5>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<input type="text" class="form-control form-control-sm" placeholder="Søg kunde..." style="width: 200px;">
|
|
||||||
<button class="btn btn-sm btn-primary"><i class="bi bi-plus"></i> Opret Kunde</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center py-1">
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<button class="btn btn-outline-secondary active">Alle</button>
|
|
||||||
<button class="btn btn-outline-secondary">Aktive</button>
|
|
||||||
<button class="btn btn-outline-secondary">Inaktive</button>
|
|
||||||
<button class="btn btn-outline-secondary">VIP</button>
|
|
||||||
</div>
|
|
||||||
<div class="text-muted small">Total: 124 kunder</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-hover mb-0 table-bordered">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 40px;">ID</th>
|
|
||||||
<th>Virksomhed</th>
|
|
||||||
<th>Kontaktperson</th>
|
|
||||||
<th>CVR</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Hardware</th>
|
|
||||||
<th class="text-end">Handling</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>101</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-bold">Advokatgruppen A/S</div>
|
|
||||||
<div class="small text-muted">København K</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Jens Jensen</div>
|
|
||||||
<div class="small text-muted">jens@advokat.dk</div>
|
|
||||||
</td>
|
|
||||||
<td>12345678</td>
|
|
||||||
<td><span class="badge bg-success">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark border">Firewall</span>
|
|
||||||
<span class="badge bg-light text-dark border">Switch</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary">Rediger</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>102</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-bold">Byg & Bo ApS</div>
|
|
||||||
<div class="small text-muted">Aarhus C</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Mette Hansen</div>
|
|
||||||
<div class="small text-muted">mh@bygbo.dk</div>
|
|
||||||
</td>
|
|
||||||
<td>87654321</td>
|
|
||||||
<td><span class="badge bg-success">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark border">Router</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary">Rediger</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>103</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-bold">Cafe Møller</div>
|
|
||||||
<div class="small text-muted">Odense M</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Peter Møller</div>
|
|
||||||
<div class="small text-muted">pm@cafe.dk</div>
|
|
||||||
</td>
|
|
||||||
<td>11223344</td>
|
|
||||||
<td><span class="badge bg-warning text-dark">Afventer</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="text-muted">-</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary">Rediger</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>104</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-bold">Dansk Design Hus</div>
|
|
||||||
<div class="small text-muted">København Ø</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Lars Larsen</div>
|
|
||||||
<div class="small text-muted">ll@design.dk</div>
|
|
||||||
</td>
|
|
||||||
<td>44332211</td>
|
|
||||||
<td><span class="badge bg-success">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark border">Firewall</span>
|
|
||||||
<span class="badge bg-light text-dark border">AP x4</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary">Rediger</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer py-1">
|
|
||||||
<nav aria-label="Page navigation">
|
|
||||||
<ul class="pagination pagination-sm justify-content-end mb-0">
|
|
||||||
<li class="page-item disabled"><a class="page-link" href="#">Forrige</a></li>
|
|
||||||
<li class="page-item active"><a class="page-link" href="#">1</a></li>
|
|
||||||
<li class="page-item"><a class="page-link" href="#">2</a></li>
|
|
||||||
<li class="page-item"><a class="page-link" href="#">3</a></li>
|
|
||||||
<li class="page-item"><a class="page-link" href="#">Næste</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,269 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Compact Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-body: #e9ecef;
|
|
||||||
--bg-sidebar: #212529;
|
|
||||||
--accent: #0d6efd;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
background: var(--bg-sidebar);
|
|
||||||
height: 100vh;
|
|
||||||
position: fixed;
|
|
||||||
width: 220px;
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: #adb5bd;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover, .nav-link.active {
|
|
||||||
color: white;
|
|
||||||
background-color: rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 220px;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-bottom: 1px solid #dee2e6;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="sidebar">
|
|
||||||
<div class="px-3 mb-3">
|
|
||||||
<h6 class="text-white fw-bold mb-0">BMC HUB <span class="badge bg-primary ms-1">v1.0</span></h6>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<a class="nav-link active" href="index.html"><i class="bi bi-speedometer2 me-2"></i> Dashboard</a>
|
|
||||||
<a class="nav-link" href="customers.html"><i class="bi bi-people me-2"></i> Kunder</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-hdd-network me-2"></i> Hardware</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-receipt me-2"></i> Fakturering</a>
|
|
||||||
<div class="border-top border-secondary my-2 mx-3"></div>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-gear me-2"></i> Indstillinger</a>
|
|
||||||
<a class="nav-link" href="#"><i class="bi bi-journal-text me-2"></i> Logs</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 bg-white p-2 border rounded">
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<h5 class="mb-0 fw-bold">Dashboard</h5>
|
|
||||||
<span class="text-muted border-start ps-3">System Status: <span class="text-success fw-bold">Online</span></span>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-clockwise"></i> Opdater</button>
|
|
||||||
<button class="btn btn-sm btn-primary"><i class="bi bi-plus"></i> Ny Opgave</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-2 mb-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body p-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<div class="text-muted small text-uppercase">Kunder</div>
|
|
||||||
<div class="fs-4 fw-bold">124</div>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-people fs-3 text-primary"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body p-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<div class="text-muted small text-uppercase">Hardware</div>
|
|
||||||
<div class="fs-4 fw-bold">856</div>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-hdd fs-3 text-success"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body p-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<div class="text-muted small text-uppercase">Support</div>
|
|
||||||
<div class="fs-4 fw-bold text-danger">12</div>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-exclamation-circle fs-3 text-danger"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body p-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<div class="text-muted small text-uppercase">Omsætning</div>
|
|
||||||
<div class="fs-4 fw-bold">450k</div>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-currency-dollar fs-3 text-info"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center py-1">
|
|
||||||
<span>Seneste Aktiviteter</span>
|
|
||||||
<input type="text" class="form-control form-control-sm w-auto" placeholder="Filter..." style="height: 24px;">
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-hover mb-0 table-bordered">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Kunde</th>
|
|
||||||
<th>Handling</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Tid</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>#1023</td>
|
|
||||||
<td>Advokatgruppen A/S</td>
|
|
||||||
<td>Firewall konfiguration</td>
|
|
||||||
<td><span class="badge bg-success">Fuldført</span></td>
|
|
||||||
<td>10:23</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>#1022</td>
|
|
||||||
<td>Byg & Bo ApS</td>
|
|
||||||
<td>Licens fornyelse</td>
|
|
||||||
<td><span class="badge bg-warning text-dark">Afventer</span></td>
|
|
||||||
<td>I går</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>#1021</td>
|
|
||||||
<td>Cafe Møller</td>
|
|
||||||
<td>Netværksnedbrud</td>
|
|
||||||
<td><span class="badge bg-danger">Kritisk</span></td>
|
|
||||||
<td>I går</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>#1020</td>
|
|
||||||
<td>Dansk Design Hus</td>
|
|
||||||
<td>Ny bruger oprettet</td>
|
|
||||||
<td><span class="badge bg-success">Fuldført</span></td>
|
|
||||||
<td>2 dage siden</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>#1019</td>
|
|
||||||
<td>Elektriker Madsen</td>
|
|
||||||
<td>VPN opsætning</td>
|
|
||||||
<td><span class="badge bg-success">Fuldført</span></td>
|
|
||||||
<td>2 dage siden</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card mb-2">
|
|
||||||
<div class="card-header py-1">System Ressourcer</div>
|
|
||||||
<div class="card-body p-2">
|
|
||||||
<div class="mb-2">
|
|
||||||
<div class="d-flex justify-content-between small mb-1">
|
|
||||||
<span>CPU</span>
|
|
||||||
<span>24%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 4px;">
|
|
||||||
<div class="progress-bar" style="width: 24%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<div class="d-flex justify-content-between small mb-1">
|
|
||||||
<span>RAM</span>
|
|
||||||
<span>56%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 4px;">
|
|
||||||
<div class="progress-bar bg-info" style="width: 56%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="d-flex justify-content-between small mb-1">
|
|
||||||
<span>Disk</span>
|
|
||||||
<span>89%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 4px;">
|
|
||||||
<div class="progress-bar bg-warning" style="width: 89%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header py-1">Hurtig Handlinger</div>
|
|
||||||
<div class="list-group list-group-flush">
|
|
||||||
<a href="#" class="list-group-item list-group-item-action py-2 small"><i class="bi bi-plus-circle me-2"></i> Opret ny kunde</a>
|
|
||||||
<a href="#" class="list-group-item list-group-item-action py-2 small"><i class="bi bi-hdd-network me-2"></i> Tilføj hardware</a>
|
|
||||||
<a href="#" class="list-group-item list-group-item-action py-2 small"><i class="bi bi-file-earmark-text me-2"></i> Se system logs</a>
|
|
||||||
<a href="#" class="list-group-item list-group-item-action py-2 small"><i class="bi bi-shield-lock me-2"></i> Sikkerhedsscan</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,292 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Glass Customers</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
--glass-bg: rgba(255, 255, 255, 0.1);
|
|
||||||
--glass-border: rgba(255, 255, 255, 0.2);
|
|
||||||
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
|
||||||
--text-white: rgba(255, 255, 255, 0.9);
|
|
||||||
--text-muted: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--bg-gradient);
|
|
||||||
min-height: 100vh;
|
|
||||||
font-family: 'Segoe UI', sans-serif;
|
|
||||||
color: var(--text-white);
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-panel {
|
|
||||||
background: var(--glass-bg);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: 24px;
|
|
||||||
box-shadow: var(--glass-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
position: fixed;
|
|
||||||
left: 20px;
|
|
||||||
top: 20px;
|
|
||||||
bottom: 20px;
|
|
||||||
width: 100px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2rem 0;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 16px;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
transition: all 0.3s;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover, .nav-item.active {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 140px;
|
|
||||||
padding: 20px 40px 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
--bs-table-bg: transparent;
|
|
||||||
--bs-table-color: var(--text-white);
|
|
||||||
--bs-table-border-color: var(--glass-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td, .table th {
|
|
||||||
padding: 1.2rem 1rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-glass {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
color: white;
|
|
||||||
border-radius: 30px;
|
|
||||||
padding: 0.7rem 1.5rem;
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-glass::placeholder { color: var(--text-muted); }
|
|
||||||
.search-glass:focus { outline: none; background: rgba(0, 0, 0, 0.3); color: white; }
|
|
||||||
|
|
||||||
.btn-glass {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
color: white;
|
|
||||||
border-radius: 30px;
|
|
||||||
padding: 0.7rem 2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-glass:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-pill {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 0.5rem 1.5rem;
|
|
||||||
border-radius: 50px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-pill:hover, .filter-pill.active {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
border-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Floating shapes for background */
|
|
||||||
.shape {
|
|
||||||
position: fixed;
|
|
||||||
border-radius: 50%;
|
|
||||||
filter: blur(80px);
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
.shape-1 { top: -10%; left: -10%; width: 500px; height: 500px; background: #ff00cc; opacity: 0.4; }
|
|
||||||
.shape-2 { bottom: -10%; right: -10%; width: 600px; height: 600px; background: #3333ff; opacity: 0.4; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="shape shape-1"></div>
|
|
||||||
<div class="shape shape-2"></div>
|
|
||||||
|
|
||||||
<nav class="sidebar glass-panel">
|
|
||||||
<div class="mb-5">
|
|
||||||
<i class="bi bi-hexagon-fill fs-2 text-white"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="index.html" class="nav-item" title="Dashboard">
|
|
||||||
<i class="bi bi-grid"></i>
|
|
||||||
</a>
|
|
||||||
<a href="customers.html" class="nav-item active" title="Kunder">
|
|
||||||
<i class="bi bi-people"></i>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" title="Hardware">
|
|
||||||
<i class="bi bi-hdd-network"></i>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" title="Fakturering">
|
|
||||||
<i class="bi bi-receipt"></i>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="mt-auto">
|
|
||||||
<a href="#" class="nav-item" title="Indstillinger">
|
|
||||||
<i class="bi bi-gear"></i>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item mb-0">
|
|
||||||
<img src="https://ui-avatars.com/api/?name=CT&background=random" class="rounded-circle" width="40">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Kunder</h2>
|
|
||||||
<p style="color: var(--text-muted)">Administrer dine kunder</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" class="search-glass" placeholder="Søg kunde...">
|
|
||||||
<button class="btn-glass"><i class="bi bi-plus-lg me-2"></i>Opret</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="glass-panel p-4">
|
|
||||||
<div class="d-flex gap-2 mb-4">
|
|
||||||
<button class="filter-pill active">Alle</button>
|
|
||||||
<button class="filter-pill">Aktive</button>
|
|
||||||
<button class="filter-pill">Inaktive</button>
|
|
||||||
<button class="filter-pill">VIP</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="color: var(--text-muted); font-weight: 400;">Virksomhed</th>
|
|
||||||
<th style="color: var(--text-muted); font-weight: 400;">Kontakt</th>
|
|
||||||
<th style="color: var(--text-muted); font-weight: 400;">CVR</th>
|
|
||||||
<th style="color: var(--text-muted); font-weight: 400;">Status</th>
|
|
||||||
<th style="color: var(--text-muted); font-weight: 400;">Hardware</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-white bg-opacity-25 d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">A</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Advokatgruppen A/S</div>
|
|
||||||
<div class="small" style="color: var(--text-muted)">København K</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Jens Jensen</div>
|
|
||||||
<div class="small" style="color: var(--text-muted)">jens@advokat.dk</div>
|
|
||||||
</td>
|
|
||||||
<td style="color: var(--text-muted)">12345678</td>
|
|
||||||
<td><span class="badge bg-success bg-opacity-25 text-white border border-success border-opacity-25">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-white bg-opacity-10 fw-normal">Firewall</span>
|
|
||||||
<span class="badge bg-white bg-opacity-10 fw-normal">Switch</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm text-white"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-white bg-opacity-25 d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">B</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Byg & Bo ApS</div>
|
|
||||||
<div class="small" style="color: var(--text-muted)">Aarhus C</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Mette Hansen</div>
|
|
||||||
<div class="small" style="color: var(--text-muted)">mh@bygbo.dk</div>
|
|
||||||
</td>
|
|
||||||
<td style="color: var(--text-muted)">87654321</td>
|
|
||||||
<td><span class="badge bg-success bg-opacity-25 text-white border border-success border-opacity-25">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-white bg-opacity-10 fw-normal">Router</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm text-white"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-white bg-opacity-25 d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">C</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Cafe Møller</div>
|
|
||||||
<div class="small" style="color: var(--text-muted)">Odense M</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>Peter Møller</div>
|
|
||||||
<div class="small" style="color: var(--text-muted)">pm@cafe.dk</div>
|
|
||||||
</td>
|
|
||||||
<td style="color: var(--text-muted)">11223344</td>
|
|
||||||
<td><span class="badge bg-warning bg-opacity-25 text-white border border-warning border-opacity-25">Afventer</span></td>
|
|
||||||
<td>
|
|
||||||
<span style="color: var(--text-muted)">-</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm text-white"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-center mt-4">
|
|
||||||
<nav>
|
|
||||||
<ul class="pagination">
|
|
||||||
<li class="page-item disabled"><a class="page-link bg-transparent border-0 text-white" href="#">Forrige</a></li>
|
|
||||||
<li class="page-item active"><a class="page-link bg-white bg-opacity-25 border-0 text-white rounded-circle mx-1" href="#">1</a></li>
|
|
||||||
<li class="page-item"><a class="page-link bg-transparent border-0 text-white" href="#">2</a></li>
|
|
||||||
<li class="page-item"><a class="page-link bg-transparent border-0 text-white" href="#">3</a></li>
|
|
||||||
<li class="page-item"><a class="page-link bg-transparent border-0 text-white" href="#">Næste</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,295 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Glass Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
--glass-bg: rgba(255, 255, 255, 0.1);
|
|
||||||
--glass-border: rgba(255, 255, 255, 0.2);
|
|
||||||
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
|
||||||
--text-white: rgba(255, 255, 255, 0.9);
|
|
||||||
--text-muted: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--bg-gradient);
|
|
||||||
min-height: 100vh;
|
|
||||||
font-family: 'Segoe UI', sans-serif;
|
|
||||||
color: var(--text-white);
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-panel {
|
|
||||||
background: var(--glass-bg);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: 24px;
|
|
||||||
box-shadow: var(--glass-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
position: fixed;
|
|
||||||
left: 20px;
|
|
||||||
top: 20px;
|
|
||||||
bottom: 20px;
|
|
||||||
width: 100px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2rem 0;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 16px;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
transition: all 0.3s;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover, .nav-item.active {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 140px;
|
|
||||||
padding: 20px 40px 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
padding: 1.5rem;
|
|
||||||
height: 100%;
|
|
||||||
transition: transform 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
--bs-table-bg: transparent;
|
|
||||||
--bs-table-color: var(--text-white);
|
|
||||||
--bs-table-border-color: var(--glass-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td, .table th {
|
|
||||||
padding: 1rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-glass {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
color: white;
|
|
||||||
border-radius: 30px;
|
|
||||||
padding: 0.7rem 1.5rem;
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-glass::placeholder { color: var(--text-muted); }
|
|
||||||
.search-glass:focus { outline: none; background: rgba(0, 0, 0, 0.3); color: white; }
|
|
||||||
|
|
||||||
.btn-glass {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
color: white;
|
|
||||||
border-radius: 30px;
|
|
||||||
padding: 0.7rem 2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-glass:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Floating shapes for background */
|
|
||||||
.shape {
|
|
||||||
position: fixed;
|
|
||||||
border-radius: 50%;
|
|
||||||
filter: blur(80px);
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
.shape-1 { top: -10%; left: -10%; width: 500px; height: 500px; background: #ff00cc; opacity: 0.4; }
|
|
||||||
.shape-2 { bottom: -10%; right: -10%; width: 600px; height: 600px; background: #3333ff; opacity: 0.4; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="shape shape-1"></div>
|
|
||||||
<div class="shape shape-2"></div>
|
|
||||||
|
|
||||||
<nav class="sidebar glass-panel">
|
|
||||||
<div class="mb-5">
|
|
||||||
<i class="bi bi-hexagon-fill fs-2 text-white"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="index.html" class="nav-item active" title="Dashboard">
|
|
||||||
<i class="bi bi-grid"></i>
|
|
||||||
</a>
|
|
||||||
<a href="customers.html" class="nav-item" title="Kunder">
|
|
||||||
<i class="bi bi-people"></i>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" title="Hardware">
|
|
||||||
<i class="bi bi-hdd-network"></i>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" title="Fakturering">
|
|
||||||
<i class="bi bi-receipt"></i>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="mt-auto">
|
|
||||||
<a href="#" class="nav-item" title="Indstillinger">
|
|
||||||
<i class="bi bi-gear"></i>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item mb-0">
|
|
||||||
<img src="https://ui-avatars.com/api/?name=CT&background=random" class="rounded-circle" width="40">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
|
||||||
<div>
|
|
||||||
<h2 class="fw-bold mb-1">Dashboard</h2>
|
|
||||||
<p style="color: var(--text-muted)">Velkommen tilbage, Christian</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<input type="text" class="search-glass" placeholder="Søg...">
|
|
||||||
<button class="btn-glass"><i class="bi bi-plus-lg me-2"></i>Ny Opgave</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4 mb-5">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="glass-panel stat-card">
|
|
||||||
<div class="d-flex justify-content-between mb-3">
|
|
||||||
<span style="color: var(--text-muted)">Aktive Kunder</span>
|
|
||||||
<i class="bi bi-people text-white"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="fw-bold mb-0">124</h2>
|
|
||||||
<small class="text-success">+12% denne måned</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="glass-panel stat-card">
|
|
||||||
<div class="d-flex justify-content-between mb-3">
|
|
||||||
<span style="color: var(--text-muted)">Hardware</span>
|
|
||||||
<i class="bi bi-hdd text-white"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="fw-bold mb-0">856</h2>
|
|
||||||
<small style="color: var(--text-muted)">Enheder online</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="glass-panel stat-card">
|
|
||||||
<div class="d-flex justify-content-between mb-3">
|
|
||||||
<span style="color: var(--text-muted)">Support</span>
|
|
||||||
<i class="bi bi-ticket text-white"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="fw-bold mb-0">12</h2>
|
|
||||||
<small class="text-warning">3 kræver handling</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="glass-panel stat-card">
|
|
||||||
<div class="d-flex justify-content-between mb-3">
|
|
||||||
<span style="color: var(--text-muted)">Omsætning</span>
|
|
||||||
<i class="bi bi-currency-dollar text-white"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="fw-bold mb-0">450k</h2>
|
|
||||||
<small class="text-success">Over budget</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="glass-panel p-4">
|
|
||||||
<h4 class="fw-bold mb-4">Seneste Aktiviteter</h4>
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="color: var(--text-muted); font-weight: 400;">Kunde</th>
|
|
||||||
<th style="color: var(--text-muted); font-weight: 400;">Handling</th>
|
|
||||||
<th style="color: var(--text-muted); font-weight: 400;">Status</th>
|
|
||||||
<th style="color: var(--text-muted); font-weight: 400;">Tid</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="fw-bold">Advokatgruppen A/S</td>
|
|
||||||
<td>Firewall konfiguration</td>
|
|
||||||
<td><span class="badge bg-success bg-opacity-25 text-white border border-success border-opacity-25">Fuldført</span></td>
|
|
||||||
<td style="color: var(--text-muted)">10:23</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="fw-bold">Byg & Bo ApS</td>
|
|
||||||
<td>Licens fornyelse</td>
|
|
||||||
<td><span class="badge bg-warning bg-opacity-25 text-white border border-warning border-opacity-25">Afventer</span></td>
|
|
||||||
<td style="color: var(--text-muted)">I går</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="fw-bold">Cafe Møller</td>
|
|
||||||
<td>Netværksnedbrud</td>
|
|
||||||
<td><span class="badge bg-danger bg-opacity-25 text-white border border-danger border-opacity-25">Kritisk</span></td>
|
|
||||||
<td style="color: var(--text-muted)">I går</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="glass-panel p-4 h-100">
|
|
||||||
<h4 class="fw-bold mb-4">System Status</h4>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<span>CPU Load</span>
|
|
||||||
<span class="fw-bold">24%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 6px; background: rgba(255,255,255,0.1);">
|
|
||||||
<div class="progress-bar bg-white" style="width: 24%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<span>Memory</span>
|
|
||||||
<span class="fw-bold">56%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 6px; background: rgba(255,255,255,0.1);">
|
|
||||||
<div class="progress-bar bg-white" style="width: 56%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto p-3 rounded" style="background: rgba(0,0,0,0.2);">
|
|
||||||
<div class="d-flex">
|
|
||||||
<i class="bi bi-check-circle-fill text-success me-2"></i>
|
|
||||||
<small>Alle systemer kører optimalt.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,346 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Material Customers</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--md-sys-color-primary: #6750A4;
|
|
||||||
--md-sys-color-on-primary: #FFFFFF;
|
|
||||||
--md-sys-color-primary-container: #EADDFF;
|
|
||||||
--md-sys-color-on-primary-container: #21005D;
|
|
||||||
--md-sys-color-surface: #FEF7FF;
|
|
||||||
--md-sys-color-surface-container: #F3EDF7;
|
|
||||||
--md-sys-color-on-surface: #1D1B20;
|
|
||||||
--md-sys-color-outline: #79747E;
|
|
||||||
--md-sys-shape-corner-large: 16px;
|
|
||||||
--md-sys-shape-corner-medium: 12px;
|
|
||||||
--md-sys-shape-corner-full: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--md-sys-color-surface);
|
|
||||||
color: var(--md-sys-color-on-surface);
|
|
||||||
font-family: 'Roboto', sans-serif;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation Rail */
|
|
||||||
.nav-rail {
|
|
||||||
width: 80px;
|
|
||||||
height: 100vh;
|
|
||||||
position: fixed;
|
|
||||||
background: var(--md-sys-color-surface);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 24px 0;
|
|
||||||
border-right: 1px solid #E7E0EC;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-fab {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
background: var(--md-sys-color-primary-container);
|
|
||||||
color: var(--md-sys-color-on-primary-container);
|
|
||||||
border-radius: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-fab:hover {
|
|
||||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--md-sys-color-on-surface);
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon-container {
|
|
||||||
width: 56px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active .nav-icon-container {
|
|
||||||
background-color: var(--md-sys-color-primary-container);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active i {
|
|
||||||
color: var(--md-sys-color-on-primary-container);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 80px;
|
|
||||||
padding: 24px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar {
|
|
||||||
background: var(--md-sys-color-surface-container);
|
|
||||||
border-radius: 28px;
|
|
||||||
padding: 0 16px;
|
|
||||||
height: 56px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar input {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
margin-left: 12px;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-md {
|
|
||||||
background: var(--md-sys-color-surface-container);
|
|
||||||
border-radius: var(--md-sys-shape-corner-large);
|
|
||||||
padding: 24px;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-filled {
|
|
||||||
background: var(--md-sys-color-primary);
|
|
||||||
color: var(--md-sys-color-on-primary);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 10px 24px;
|
|
||||||
border: none;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.1px;
|
|
||||||
transition: box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-filled:hover {
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--md-sys-color-outline);
|
|
||||||
border-bottom: 1px solid #E7E0EC;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 16px 8px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #79747E;
|
|
||||||
margin-right: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip.active {
|
|
||||||
background: var(--md-sys-color-primary-container);
|
|
||||||
border-color: transparent;
|
|
||||||
color: var(--md-sys-color-on-primary-container);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip:hover:not(.active) {
|
|
||||||
background: rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav class="nav-rail">
|
|
||||||
<div class="nav-fab">
|
|
||||||
<i class="bi bi-pencil-fill fs-5"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="index.html" class="nav-item">
|
|
||||||
<div class="nav-icon-container">
|
|
||||||
<i class="bi bi-grid"></i>
|
|
||||||
</div>
|
|
||||||
<span class="nav-label">Home</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="customers.html" class="nav-item active">
|
|
||||||
<div class="nav-icon-container">
|
|
||||||
<i class="bi bi-people-fill"></i>
|
|
||||||
</div>
|
|
||||||
<span class="nav-label">Kunder</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="#" class="nav-item">
|
|
||||||
<div class="nav-icon-container">
|
|
||||||
<i class="bi bi-hdd"></i>
|
|
||||||
</div>
|
|
||||||
<span class="nav-label">Udstyr</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="#" class="nav-item">
|
|
||||||
<div class="nav-icon-container">
|
|
||||||
<i class="bi bi-gear"></i>
|
|
||||||
</div>
|
|
||||||
<span class="nav-label">Opsætning</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="top-bar">
|
|
||||||
<h2 class="m-0 fw-normal">Kunder</h2>
|
|
||||||
<div class="search-bar">
|
|
||||||
<i class="bi bi-search text-muted"></i>
|
|
||||||
<input type="text" placeholder="Søg efter kunde...">
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<button class="btn-filled"><i class="bi bi-plus-lg me-2"></i>Opret Kunde</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<button class="chip active">Alle Kunder</button>
|
|
||||||
<button class="chip">Aktive</button>
|
|
||||||
<button class="chip">Inaktive</button>
|
|
||||||
<button class="chip">VIP</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-md">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Virksomhed</th>
|
|
||||||
<th>Kontaktperson</th>
|
|
||||||
<th>CVR</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Hardware</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">A</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-medium">Advokatgruppen A/S</div>
|
|
||||||
<div class="small text-muted">København K</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-medium">Jens Jensen</div>
|
|
||||||
<div class="small text-muted">jens@advokat.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">12345678</td>
|
|
||||||
<td><span class="badge rounded-pill text-bg-success bg-opacity-25 text-success">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-secondary bg-opacity-10 text-secondary border">Firewall</span>
|
|
||||||
<span class="badge bg-secondary bg-opacity-10 text-secondary border">Switch</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-icon"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">B</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-medium">Byg & Bo ApS</div>
|
|
||||||
<div class="small text-muted">Aarhus C</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-medium">Mette Hansen</div>
|
|
||||||
<div class="small text-muted">mh@bygbo.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">87654321</td>
|
|
||||||
<td><span class="badge rounded-pill text-bg-success bg-opacity-25 text-success">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-secondary bg-opacity-10 text-secondary border">Router</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-icon"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-danger text-white d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">C</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-medium">Cafe Møller</div>
|
|
||||||
<div class="small text-muted">Odense M</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-medium">Peter Møller</div>
|
|
||||||
<div class="small text-muted">pm@cafe.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">11223344</td>
|
|
||||||
<td><span class="badge rounded-pill text-bg-warning bg-opacity-25 text-warning">Afventer</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="text-muted">-</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-icon"><i class="bi bi-three-dots-vertical"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-end mt-4">
|
|
||||||
<nav>
|
|
||||||
<ul class="pagination">
|
|
||||||
<li class="page-item disabled"><a class="page-link border-0 bg-transparent" href="#">Forrige</a></li>
|
|
||||||
<li class="page-item active"><a class="page-link border-0 rounded-circle mx-1" style="background-color: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container);" href="#">1</a></li>
|
|
||||||
<li class="page-item"><a class="page-link border-0 bg-transparent text-dark" href="#">2</a></li>
|
|
||||||
<li class="page-item"><a class="page-link border-0 bg-transparent text-dark" href="#">3</a></li>
|
|
||||||
<li class="page-item"><a class="page-link border-0 bg-transparent text-dark" href="#">Næste</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,350 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Material Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--md-sys-color-primary: #6750A4;
|
|
||||||
--md-sys-color-on-primary: #FFFFFF;
|
|
||||||
--md-sys-color-primary-container: #EADDFF;
|
|
||||||
--md-sys-color-on-primary-container: #21005D;
|
|
||||||
--md-sys-color-surface: #FEF7FF;
|
|
||||||
--md-sys-color-surface-container: #F3EDF7;
|
|
||||||
--md-sys-color-on-surface: #1D1B20;
|
|
||||||
--md-sys-color-outline: #79747E;
|
|
||||||
--md-sys-shape-corner-large: 16px;
|
|
||||||
--md-sys-shape-corner-medium: 12px;
|
|
||||||
--md-sys-shape-corner-full: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--md-sys-color-surface);
|
|
||||||
color: var(--md-sys-color-on-surface);
|
|
||||||
font-family: 'Roboto', sans-serif;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation Rail */
|
|
||||||
.nav-rail {
|
|
||||||
width: 80px;
|
|
||||||
height: 100vh;
|
|
||||||
position: fixed;
|
|
||||||
background: var(--md-sys-color-surface);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 24px 0;
|
|
||||||
border-right: 1px solid #E7E0EC;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-fab {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
background: var(--md-sys-color-primary-container);
|
|
||||||
color: var(--md-sys-color-on-primary-container);
|
|
||||||
border-radius: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-fab:hover {
|
|
||||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--md-sys-color-on-surface);
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon-container {
|
|
||||||
width: 56px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active .nav-icon-container {
|
|
||||||
background-color: var(--md-sys-color-primary-container);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active i {
|
|
||||||
color: var(--md-sys-color-on-primary-container);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: 80px;
|
|
||||||
padding: 24px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar {
|
|
||||||
background: var(--md-sys-color-surface-container);
|
|
||||||
border-radius: 28px;
|
|
||||||
padding: 0 16px;
|
|
||||||
height: 56px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar input {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
margin-left: 12px;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-md {
|
|
||||||
background: var(--md-sys-color-surface-container);
|
|
||||||
border-radius: var(--md-sys-shape-corner-large);
|
|
||||||
padding: 24px;
|
|
||||||
border: none;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-filled {
|
|
||||||
background: var(--md-sys-color-primary);
|
|
||||||
color: var(--md-sys-color-on-primary);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 10px 24px;
|
|
||||||
border: none;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.1px;
|
|
||||||
transition: box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-filled:hover {
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--md-sys-color-outline);
|
|
||||||
border-bottom: 1px solid #E7E0EC;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 16px 8px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav class="nav-rail">
|
|
||||||
<div class="nav-fab">
|
|
||||||
<i class="bi bi-pencil-fill fs-5"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="index.html" class="nav-item active">
|
|
||||||
<div class="nav-icon-container">
|
|
||||||
<i class="bi bi-grid-fill"></i>
|
|
||||||
</div>
|
|
||||||
<span class="nav-label">Home</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="customers.html" class="nav-item">
|
|
||||||
<div class="nav-icon-container">
|
|
||||||
<i class="bi bi-people"></i>
|
|
||||||
</div>
|
|
||||||
<span class="nav-label">Kunder</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="#" class="nav-item">
|
|
||||||
<div class="nav-icon-container">
|
|
||||||
<i class="bi bi-hdd"></i>
|
|
||||||
</div>
|
|
||||||
<span class="nav-label">Udstyr</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="#" class="nav-item">
|
|
||||||
<div class="nav-icon-container">
|
|
||||||
<i class="bi bi-gear"></i>
|
|
||||||
</div>
|
|
||||||
<span class="nav-label">Opsætning</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<div class="top-bar">
|
|
||||||
<h2 class="m-0 fw-normal">Dashboard</h2>
|
|
||||||
<div class="search-bar">
|
|
||||||
<i class="bi bi-search text-muted"></i>
|
|
||||||
<input type="text" placeholder="Søg i BMC Hub...">
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<button class="btn btn-icon rounded-circle"><i class="bi bi-bell fs-5"></i></button>
|
|
||||||
<img src="https://ui-avatars.com/api/?name=CT&background=6750A4&color=fff" class="rounded-circle" width="40">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card-md">
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<i class="bi bi-people fs-4 me-2" style="color: var(--md-sys-color-primary)"></i>
|
|
||||||
<span class="fw-medium">Kunder</span>
|
|
||||||
</div>
|
|
||||||
<h2 class="display-5 fw-bold mb-0">124</h2>
|
|
||||||
<small class="text-muted">Aktive abonnementer</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card-md">
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<i class="bi bi-hdd-network fs-4 me-2" style="color: var(--md-sys-color-primary)"></i>
|
|
||||||
<span class="fw-medium">Hardware</span>
|
|
||||||
</div>
|
|
||||||
<h2 class="display-5 fw-bold mb-0">856</h2>
|
|
||||||
<small class="text-muted">Enheder registreret</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card-md">
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<i class="bi bi-exclamation-circle fs-4 me-2 text-danger"></i>
|
|
||||||
<span class="fw-medium">Support</span>
|
|
||||||
</div>
|
|
||||||
<h2 class="display-5 fw-bold mb-0">12</h2>
|
|
||||||
<small class="text-muted">Åbne sager</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card-md">
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<i class="bi bi-graph-up fs-4 me-2 text-success"></i>
|
|
||||||
<span class="fw-medium">Omsætning</span>
|
|
||||||
</div>
|
|
||||||
<h2 class="display-5 fw-bold mb-0">450k</h2>
|
|
||||||
<small class="text-muted">Denne måned</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card-md">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h5 class="m-0">Seneste aktivitet</h5>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary rounded-pill px-3">Se alle</button>
|
|
||||||
</div>
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Kunde</th>
|
|
||||||
<th>Handling</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Tid</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px; font-size: 14px;">A</div>
|
|
||||||
<span class="fw-medium">Advokatgruppen A/S</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>Firewall konfiguration</td>
|
|
||||||
<td><span class="badge rounded-pill text-bg-success bg-opacity-25 text-success">Fuldført</span></td>
|
|
||||||
<td class="text-muted">10:23</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px; font-size: 14px;">B</div>
|
|
||||||
<span class="fw-medium">Byg & Bo ApS</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>Licens fornyelse</td>
|
|
||||||
<td><span class="badge rounded-pill text-bg-warning bg-opacity-25 text-warning">Afventer</span></td>
|
|
||||||
<td class="text-muted">I går</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="rounded-circle bg-danger text-white d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px; font-size: 14px;">C</div>
|
|
||||||
<span class="fw-medium">Cafe Møller</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>Netværksnedbrud</td>
|
|
||||||
<td><span class="badge rounded-pill text-bg-danger bg-opacity-25 text-danger">Kritisk</span></td>
|
|
||||||
<td class="text-muted">I går</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card-md h-100">
|
|
||||||
<h5 class="mb-4">System Status</h5>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="d-flex justify-content-between mb-1">
|
|
||||||
<span class="text-muted small">CPU</span>
|
|
||||||
<span class="fw-bold small">24%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 8px; border-radius: 4px;">
|
|
||||||
<div class="progress-bar" style="width: 24%; background-color: var(--md-sys-color-primary);"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="d-flex justify-content-between mb-1">
|
|
||||||
<span class="text-muted small">RAM</span>
|
|
||||||
<span class="fw-bold small">56%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress" style="height: 8px; border-radius: 4px;">
|
|
||||||
<div class="progress-bar" style="width: 56%; background-color: var(--md-sys-color-primary);"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto p-3 rounded-4" style="background-color: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container);">
|
|
||||||
<div class="d-flex">
|
|
||||||
<i class="bi bi-info-circle-fill me-2"></i>
|
|
||||||
<small class="fw-medium">System backup kører kl. 03:00</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,225 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>BMC Hub - Horizontal Clean Customers</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--primary: #2563eb;
|
|
||||||
--bg-body: #f3f4f6;
|
|
||||||
--bg-nav: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-body);
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
padding-top: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
background-color: var(--bg-nav);
|
|
||||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
||||||
height: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-weight: 700;
|
|
||||||
color: #111827;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: #4b5563;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 0.5rem 1rem !important;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover {
|
|
||||||
color: var(--primary);
|
|
||||||
background-color: #eff6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link.active {
|
|
||||||
color: var(--primary);
|
|
||||||
background-color: #eff6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--primary);
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-lg fixed-top">
|
|
||||||
<div class="container">
|
|
||||||
<a class="navbar-brand d-flex align-items-center" href="#">
|
|
||||||
<div class="bg-primary text-white rounded p-1 me-2 d-flex align-items-center justify-content-center" style="width: 32px; height: 32px;">
|
|
||||||
<i class="bi bi-hdd-network-fill" style="font-size: 16px;"></i>
|
|
||||||
</div>
|
|
||||||
BMC Hub
|
|
||||||
</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav mx-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="index.html">Dashboard</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" href="customers.html">Kunder</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="#">Hardware</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="#">Fakturering</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="#">Rapporter</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
|
||||||
<button class="btn btn-light rounded-circle border"><i class="bi bi-bell"></i></button>
|
|
||||||
<div class="dropdown">
|
|
||||||
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="dropdown">
|
|
||||||
<img src="https://ui-avatars.com/api/?name=CT&background=random" class="rounded-circle me-2" width="32">
|
|
||||||
<span class="small fw-bold">Christian</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="container py-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h2 class="fw-bold mb-0">Kunder</h2>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<input type="text" class="form-control" placeholder="Søg kunde..." style="width: 250px;">
|
|
||||||
<button class="btn btn-primary">Opret Kunde</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header bg-white border-bottom py-3 d-flex gap-2">
|
|
||||||
<button class="btn btn-sm btn-dark rounded-pill px-3">Alle</button>
|
|
||||||
<button class="btn btn-sm btn-light border rounded-pill px-3">Aktive</button>
|
|
||||||
<button class="btn btn-sm btn-light border rounded-pill px-3">Inaktive</button>
|
|
||||||
<button class="btn btn-sm btn-light border rounded-pill px-3">VIP</button>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover mb-0 align-middle">
|
|
||||||
<thead class="bg-light">
|
|
||||||
<tr>
|
|
||||||
<th class="border-0 text-muted small text-uppercase fw-bold ps-4">Virksomhed</th>
|
|
||||||
<th class="border-0 text-muted small text-uppercase fw-bold">Kontakt</th>
|
|
||||||
<th class="border-0 text-muted small text-uppercase fw-bold">CVR</th>
|
|
||||||
<th class="border-0 text-muted small text-uppercase fw-bold">Status</th>
|
|
||||||
<th class="border-0 text-muted small text-uppercase fw-bold">Hardware</th>
|
|
||||||
<th class="border-0 text-muted small text-uppercase fw-bold pe-4 text-end"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-4">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="bg-light rounded p-2 me-3 text-primary fw-bold">AG</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Advokatgruppen A/S</div>
|
|
||||||
<div class="small text-muted">København K</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-medium">Jens Jensen</div>
|
|
||||||
<div class="small text-muted">jens@advokat.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">12345678</td>
|
|
||||||
<td><span class="badge bg-success bg-opacity-10 text-success rounded-pill px-3">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark border fw-normal">Firewall</span>
|
|
||||||
<span class="badge bg-light text-dark border fw-normal">Switch</span>
|
|
||||||
</td>
|
|
||||||
<td class="pe-4 text-end">
|
|
||||||
<button class="btn btn-sm btn-light border"><i class="bi bi-three-dots"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-4">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="bg-light rounded p-2 me-3 text-primary fw-bold">BB</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Byg & Bo ApS</div>
|
|
||||||
<div class="small text-muted">Aarhus C</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-medium">Mette Hansen</div>
|
|
||||||
<div class="small text-muted">mh@bygbo.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">87654321</td>
|
|
||||||
<td><span class="badge bg-success bg-opacity-10 text-success rounded-pill px-3">Aktiv</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark border fw-normal">Router</span>
|
|
||||||
</td>
|
|
||||||
<td class="pe-4 text-end">
|
|
||||||
<button class="btn btn-sm btn-light border"><i class="bi bi-three-dots"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="ps-4">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="bg-light rounded p-2 me-3 text-primary fw-bold">CM</div>
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold">Cafe Møller</div>
|
|
||||||
<div class="small text-muted">Odense M</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="fw-medium">Peter Møller</div>
|
|
||||||
<div class="small text-muted">pm@cafe.dk</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">11223344</td>
|
|
||||||
<td><span class="badge bg-warning bg-opacity-10 text-warning rounded-pill px-3">Afventer</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="text-muted">-</span>
|
|
||||||
</td>
|
|
||||||
<td class="pe-4 text-end">
|
|
||||||
<button class="btn btn-sm btn-light border"><i class="bi bi-three-dots"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer bg-white py-3">
|
|
||||||
<nav>
|
|
||||||
<ul class="pagination justify-content-center mb-0">
|
|
||||||
<li class="page-item disabled"><a class="page-link border-0" href="#">Forrige</a></li>
|
|
||||||
<li class="page-item active"><a class="page-link border-0 bg-primary rounded-circle mx-1" href="#">1</a></li>
|
|
||||||
<li class="page-item"><a class="page-link border-0 text-dark" href="#">2</a></li>
|
|
||||||
<li class="page-item"><a class="page-link border-0 text-dark" href="#">3</a></li>
|
|
||||||
<li class="page-item"><a class="page-link border-0 text-dark" href="#">Næste</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user