Compare commits

..

No commits in common. "3a8288f5a137587a7aba25bce261e69047fe03d4" and "731a541f0044dc1ee9f3a6ddec6ec8559b3451e4" have entirely different histories.

110 changed files with 143 additions and 30218 deletions

View File

@ -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

View File

@ -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
- 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
### Local Development Setup
@ -150,9 +132,6 @@ if settings.ECONOMIC_READ_ONLY:
- For health checks: return dict with `status`, `service`, `version` keys
### 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
- **Services in `app/services/`** for business logic (e.g., `economic.py` for API integration)
- **Jobs in `app/jobs/`** for scheduled tasks

View File

@ -8,9 +8,6 @@ RUN apt-get update && apt-get install -y \
git \
libpq-dev \
gcc \
tesseract-ocr \
tesseract-ocr-dan \
tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/*
# Build arguments for GitHub release deployment

View File

@ -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']
}

View File

@ -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}
)

View File

@ -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 &copy; 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

View File

@ -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

View File

@ -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>

View File

@ -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"
})

View File

@ -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))

View File

@ -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
})

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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

View File

@ -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}")

View File

@ -33,22 +33,9 @@ class Settings(BaseSettings):
ECONOMIC_READ_ONLY: 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:
env_file = ".env"
case_sensitive = True
extra = "ignore" # Ignore extra fields from .env
settings = Settings()

View File

@ -55,103 +55,19 @@ def get_db():
release_db_connection(conn)
def execute_query(query: str, params: tuple = None, fetchone: bool = False):
"""
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
"""
def execute_query(query: str, params: tuple = None, fetch: bool = True):
"""Execute a SQL query and return results"""
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query, params or ())
# Check if this is a write operation (INSERT, UPDATE, DELETE)
query_upper = query.strip().upper()
is_write = any(query_upper.startswith(cmd) for cmd in ['INSERT', 'UPDATE', 'DELETE'])
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]
cursor.execute(query, params)
if fetch:
return cursor.fetchall()
conn.commit()
return cursor.rowcount
except Exception as e:
conn.rollback()
logger.error(f"Query error: {e}")
raise
finally:
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)

View File

@ -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

View File

@ -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
})

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 []

View File

@ -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})

View File

@ -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 %}

View File

@ -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 []
}

View File

@ -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
})

View File

@ -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 %}

View File

@ -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 %}

View File

@ -3,11 +3,10 @@ Pydantic Models and Schemas
"""
from pydantic import BaseModel
from typing import Optional, List
from typing import Optional
from datetime import datetime
# Customer Schemas
class CustomerBase(BaseModel):
"""Base customer schema"""
name: str
@ -16,30 +15,9 @@ class CustomerBase(BaseModel):
address: Optional[str] = None
class CustomerCreate(BaseModel):
class CustomerCreate(CustomerBase):
"""Schema for creating a customer"""
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
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
pass
class Customer(CustomerBase):
@ -52,70 +30,6 @@ class Customer(CustomerBase):
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):
"""Base hardware schema"""
serial_number: str
@ -135,69 +49,3 @@ class Hardware(HardwareBase):
class Config:
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
View File

@ -0,0 +1 @@
"""Routers package"""

View File

@ -4,13 +4,9 @@ API endpoints for billing operations
"""
from fastapi import APIRouter
from . import supplier_invoices
router = APIRouter()
# Include supplier invoices router
router.include_router(supplier_invoices.router, prefix="", tags=["Supplier Invoices"])
@router.get("/billing/invoices")
async def list_invoices():

47
app/routers/customers.py Normal file
View 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]

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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 hvor sikker du er 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 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 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()

View File

@ -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()

View File

@ -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

View File

@ -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"
})

View File

@ -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 %}

View File

@ -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, "&apos;")})'>
<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>

View File

@ -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"}

View File

@ -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"
})

View File

@ -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 %}

View File

@ -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 %}

View File

@ -42,17 +42,12 @@ services:
# 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 # Bruger Chat API format
- OLLAMA_MODEL_FALLBACK=qwen2.5:3b # Backup model
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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!

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
View File

@ -12,26 +12,12 @@ from contextlib import asynccontextmanager
from app.core.config import settings
from app.core.database import init_db
# Import Feature Routers
from app.auth.backend import router as auth_api
from app.auth.backend import views as auth_views
from app.customers.backend import router as customers_api
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
from app.routers import (
customers,
hardware,
billing,
system,
)
# Configure logging
logging.basicConfig(
@ -78,11 +64,6 @@ app = FastAPI(
openapi_url="/api/openapi.json"
)
@app.get("/")
async def root():
"""Redirect root to dashboard"""
return RedirectResponse(url="/dashboard")
# CORS middleware
app.add_middleware(
CORSMiddleware,
@ -93,30 +74,19 @@ app.add_middleware(
)
# Include routers
app.include_router(auth_api.router, prefix="/api/v1/auth", tags=["Authentication"])
app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"])
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
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"])
app.include_router(customers.router, prefix="/api/v1", tags=["Customers"])
app.include_router(hardware.router, prefix="/api/v1", tags=["Hardware"])
app.include_router(billing.router, prefix="/api/v1", tags=["Billing"])
app.include_router(system.router, prefix="/api/v1", tags=["System"])
# Serve static files (UI)
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")
async def health_check():
"""Health check endpoint"""

View File

@ -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();

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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)';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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);

View File

@ -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

View File

@ -5,15 +5,3 @@ pydantic==2.10.3
pydantic-settings==2.6.1
python-dotenv==1.0.1
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

View File

@ -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())

View File

@ -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}")

View File

@ -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()

View File

@ -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}")

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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