feat: Implement Vendors API and Frontend
- Added a new API router for managing vendors with endpoints for listing, creating, updating, retrieving, and deleting vendors. - Implemented frontend views for displaying vendor lists and details using Jinja2 templates. - Created HTML templates for vendor list and detail pages with responsive design and dynamic content loading. - Added JavaScript functionality for vendor management, including pagination, filtering, and modal forms for creating new vendors. - Introduced a settings table in the database for system configuration and extended the users table with additional fields. - Developed a script to import vendors from an OmniSync database into the PostgreSQL database, handling errors and logging progress.
This commit is contained in:
parent
050e886f22
commit
3a35042788
64
app/dashboard/backend/router.py
Normal file
64
app/dashboard/backend/router.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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))
|
||||||
@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app")
|
templates = Jinja2Templates(directory="app")
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/dashboard", response_class=HTMLResponse)
|
||||||
async def dashboard(request: Request):
|
async def dashboard(request: Request):
|
||||||
"""
|
"""
|
||||||
Render the dashboard page
|
Render the dashboard page
|
||||||
|
|||||||
@ -9,123 +9,199 @@
|
|||||||
<p class="text-muted mb-0">Velkommen tilbage, Christian</p>
|
<p class="text-muted mb-0">Velkommen tilbage, Christian</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-3">
|
<div class="d-flex gap-3">
|
||||||
<input type="text" class="header-search" placeholder="Søg...">
|
<div class="input-group">
|
||||||
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Ny Opgave</button>
|
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" class="form-control border-start-0 ps-0" placeholder="Søg i alt..." style="max-width: 250px;">
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 1. Live Metrics Cards -->
|
||||||
<div class="row g-4 mb-5">
|
<div class="row g-4 mb-5">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card stat-card p-4 h-100">
|
<div class="card stat-card p-4 h-100">
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<p>Aktive Kunder</p>
|
<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>
|
<i class="bi bi-people text-primary" style="color: var(--accent) !important;"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3>124</h3>
|
<h3 id="contactCount">-</h3>
|
||||||
<small class="text-success"><i class="bi bi-arrow-up-short"></i> 12% denne måned</small>
|
<small class="text-muted">Tilknyttede personer</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card stat-card p-4 h-100">
|
<div class="card stat-card p-4 h-100">
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<p>Hardware</p>
|
<p>Leverandører</p>
|
||||||
<i class="bi bi-hdd text-primary" style="color: var(--accent) !important;"></i>
|
<i class="bi bi-shop text-primary" style="color: var(--accent) !important;"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3>856</h3>
|
<h3 id="vendorCount">-</h3>
|
||||||
<small class="text-muted">Enheder online</small>
|
<small class="text-muted">Aktive leverandøraftaler</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card stat-card p-4 h-100">
|
<div class="card stat-card p-4 h-100">
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<p>Support</p>
|
<p>System Status</p>
|
||||||
<i class="bi bi-ticket text-primary" style="color: var(--accent) !important;"></i>
|
<i class="bi bi-cpu text-primary" style="color: var(--accent) !important;"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3>12</h3>
|
<h3 id="systemStatus" class="text-success">Online</h3>
|
||||||
<small class="text-warning">3 kræver handling</small>
|
<small class="text-muted" id="systemVersion">v1.0.0</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
|
<!-- 2. Recent Activity (New Customers) -->
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<div class="card p-4">
|
<div class="card p-4 h-100">
|
||||||
<h5 class="fw-bold mb-4">Seneste Aktiviteter</h5>
|
<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">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle">
|
<table class="table table-hover align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Kunde</th>
|
<th>Navn</th>
|
||||||
<th>Handling</th>
|
<th>Type</th>
|
||||||
<th>Status</th>
|
<th>Oprettet</th>
|
||||||
<th class="text-end">Tid</th>
|
<th class="text-end">Handling</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="recentActivityTable">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fw-bold">Advokatgruppen A/S</td>
|
<td colspan="4" class="text-center py-4">
|
||||||
<td>Firewall konfiguration</td>
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
<td><span class="badge bg-success bg-opacity-10 text-success">Fuldført</span></td>
|
</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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<!-- 3. Vendor Distribution & Quick Links -->
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="col-lg-4">
|
||||||
<span class="small fw-bold text-muted">MEMORY</span>
|
<div class="card p-4 mb-4">
|
||||||
<span class="small fw-bold">56%</span>
|
<h5 class="fw-bold mb-4">Leverandør Fordeling</h5>
|
||||||
</div>
|
<div id="vendorDistribution">
|
||||||
<div class="progress" style="height: 8px; background-color: var(--accent-light);">
|
<div class="text-center py-3">
|
||||||
<div class="progress-bar" style="width: 56%; background-color: var(--accent);"></div>
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-auto p-3 rounded" style="background-color: var(--accent-light);">
|
|
||||||
<div class="d-flex">
|
<!-- 4. Quick Actions / Shortcuts -->
|
||||||
<i class="bi bi-check-circle-fill text-success me-2"></i>
|
<div class="card p-4">
|
||||||
<small class="fw-bold" style="color: var(--accent)">Alle systemer kører optimalt.</small>
|
<h5 class="fw-bold mb-3">Genveje</h5>
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
239
app/settings/backend/router.py
Normal file
239
app/settings/backend/router.py
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
"""
|
||||||
|
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"}
|
||||||
19
app/settings/backend/views.py
Normal file
19
app/settings/backend/views.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
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"
|
||||||
|
})
|
||||||
508
app/settings/frontend/settings.html
Normal file
508
app/settings/frontend/settings.html
Normal file
@ -0,0 +1,508 @@
|
|||||||
|
{% 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="#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>
|
||||||
|
|
||||||
|
<!-- 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load on page ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadSettings();
|
||||||
|
loadUsers();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -170,7 +170,7 @@
|
|||||||
<ul class="dropdown-menu mt-2">
|
<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="/customers">Kunder</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/contacts">Kontakter</a></li>
|
<li><a class="dropdown-item py-2" href="/contacts">Kontakter</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Leverandører</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><a class="dropdown-item py-2" href="#">Leads</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
|
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
|
||||||
@ -224,7 +224,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end mt-2">
|
<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="#">Profil</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Indstillinger</a></li>
|
<li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item py-2 text-danger" href="#">Log ud</a></li>
|
<li><a class="dropdown-item py-2 text-danger" href="#">Log ud</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
174
app/vendors/backend/router.py
vendored
Normal file
174
app/vendors/backend/router.py
vendored
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Vendors API Router
|
||||||
|
Endpoints for managing suppliers and vendors
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from typing import List, Optional
|
||||||
|
from app.models.schemas import Vendor, VendorCreate, VendorUpdate
|
||||||
|
from app.core.database import execute_query
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vendors", response_model=List[Vendor], tags=["Vendors"])
|
||||||
|
async def list_vendors(
|
||||||
|
search: Optional[str] = Query(None, description="Search by name, CVR, or domain"),
|
||||||
|
category: Optional[str] = Query(None, description="Filter by category"),
|
||||||
|
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100)
|
||||||
|
):
|
||||||
|
"""Get list of vendors with optional filtering"""
|
||||||
|
query = "SELECT * FROM vendors WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query += " AND (name ILIKE %s OR cvr_number ILIKE %s OR domain ILIKE %s)"
|
||||||
|
search_param = f"%{search}%"
|
||||||
|
params.extend([search_param, search_param, search_param])
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query += " AND category = %s"
|
||||||
|
params.append(category)
|
||||||
|
|
||||||
|
if is_active is not None:
|
||||||
|
query += " AND is_active = %s"
|
||||||
|
params.append(is_active)
|
||||||
|
|
||||||
|
query += " ORDER BY name LIMIT %s OFFSET %s"
|
||||||
|
params.extend([limit, skip])
|
||||||
|
|
||||||
|
result = execute_query(query, tuple(params))
|
||||||
|
return result or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vendors/{vendor_id}", response_model=Vendor, tags=["Vendors"])
|
||||||
|
async def get_vendor(vendor_id: int):
|
||||||
|
"""Get vendor by ID"""
|
||||||
|
query = "SELECT * FROM vendors WHERE id = %s"
|
||||||
|
result = execute_query(query, (vendor_id,))
|
||||||
|
|
||||||
|
if not result or len(result) == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Vendor not found")
|
||||||
|
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vendors", response_model=Vendor, tags=["Vendors"])
|
||||||
|
async def create_vendor(vendor: VendorCreate):
|
||||||
|
"""Create a new vendor"""
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
INSERT INTO vendors (
|
||||||
|
name, cvr_number, email, phone, address, postal_code, city,
|
||||||
|
website, domain, email_pattern, category, priority, notes, is_active
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
params = (
|
||||||
|
vendor.name, vendor.cvr_number, vendor.email, vendor.phone,
|
||||||
|
vendor.address, vendor.postal_code, vendor.city, vendor.website,
|
||||||
|
vendor.domain, vendor.email_pattern, vendor.category, vendor.priority,
|
||||||
|
vendor.notes, vendor.is_active
|
||||||
|
)
|
||||||
|
|
||||||
|
result = execute_query(query, params)
|
||||||
|
if not result or len(result) == 0:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create vendor")
|
||||||
|
|
||||||
|
logger.info(f"✅ Created vendor: {vendor.name}")
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error creating vendor: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/vendors/{vendor_id}", response_model=Vendor, tags=["Vendors"])
|
||||||
|
async def update_vendor(vendor_id: int, vendor: VendorUpdate):
|
||||||
|
"""Update an existing vendor"""
|
||||||
|
# Check if vendor exists
|
||||||
|
existing = execute_query("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Vendor not found")
|
||||||
|
|
||||||
|
# Build update query
|
||||||
|
update_fields = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if vendor.name is not None:
|
||||||
|
update_fields.append("name = %s")
|
||||||
|
params.append(vendor.name)
|
||||||
|
if vendor.cvr_number is not None:
|
||||||
|
update_fields.append("cvr_number = %s")
|
||||||
|
params.append(vendor.cvr_number)
|
||||||
|
if vendor.email is not None:
|
||||||
|
update_fields.append("email = %s")
|
||||||
|
params.append(vendor.email)
|
||||||
|
if vendor.phone is not None:
|
||||||
|
update_fields.append("phone = %s")
|
||||||
|
params.append(vendor.phone)
|
||||||
|
if vendor.address is not None:
|
||||||
|
update_fields.append("address = %s")
|
||||||
|
params.append(vendor.address)
|
||||||
|
if vendor.postal_code is not None:
|
||||||
|
update_fields.append("postal_code = %s")
|
||||||
|
params.append(vendor.postal_code)
|
||||||
|
if vendor.city is not None:
|
||||||
|
update_fields.append("city = %s")
|
||||||
|
params.append(vendor.city)
|
||||||
|
if vendor.website is not None:
|
||||||
|
update_fields.append("website = %s")
|
||||||
|
params.append(vendor.website)
|
||||||
|
if vendor.domain is not None:
|
||||||
|
update_fields.append("domain = %s")
|
||||||
|
params.append(vendor.domain)
|
||||||
|
if vendor.email_pattern is not None:
|
||||||
|
update_fields.append("email_pattern = %s")
|
||||||
|
params.append(vendor.email_pattern)
|
||||||
|
if vendor.category is not None:
|
||||||
|
update_fields.append("category = %s")
|
||||||
|
params.append(vendor.category)
|
||||||
|
if vendor.priority is not None:
|
||||||
|
update_fields.append("priority = %s")
|
||||||
|
params.append(vendor.priority)
|
||||||
|
if vendor.notes is not None:
|
||||||
|
update_fields.append("notes = %s")
|
||||||
|
params.append(vendor.notes)
|
||||||
|
if vendor.is_active is not None:
|
||||||
|
update_fields.append("is_active = %s")
|
||||||
|
params.append(vendor.is_active)
|
||||||
|
|
||||||
|
if not update_fields:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
params.append(vendor_id)
|
||||||
|
query = f"UPDATE vendors SET {', '.join(update_fields)} WHERE id = %s RETURNING *"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = execute_query(query, tuple(params))
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to update vendor")
|
||||||
|
|
||||||
|
logger.info(f"✅ Updated vendor: {vendor_id}")
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error updating vendor: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/vendors/{vendor_id}", tags=["Vendors"])
|
||||||
|
async def delete_vendor(vendor_id: int):
|
||||||
|
"""Soft delete a vendor (set is_active = false)"""
|
||||||
|
query = "UPDATE vendors SET is_active = false WHERE id = %s RETURNING id"
|
||||||
|
result = execute_query(query, (vendor_id,))
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Vendor not found")
|
||||||
|
|
||||||
|
logger.info(f"✅ Deleted vendor: {vendor_id}")
|
||||||
|
return {"message": "Vendor deleted successfully"}
|
||||||
33
app/vendors/backend/views.py
vendored
Normal file
33
app/vendors/backend/views.py
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
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"
|
||||||
|
})
|
||||||
422
app/vendors/frontend/vendor_detail.html
vendored
Normal file
422
app/vendors/frontend/vendor_detail.html
vendored
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
{% 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">
|
||||||
|
<h5 class="mb-4 fw-bold">Leverandør Fakturaer</h5>
|
||||||
|
<p class="text-muted">Faktura oversigt kommer snart...</p>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
<div class="info-row">
|
||||||
|
<div class="info-label">Prioritet</div>
|
||||||
|
<div class="info-value">
|
||||||
|
<div class="priority-indicator ${getPriorityClass(vendor.priority)}">
|
||||||
|
${vendor.priority}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editVendor() {
|
||||||
|
// TODO: Implement edit modal
|
||||||
|
alert('Edit funktion kommer snart!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 %}
|
||||||
423
app/vendors/frontend/vendors.html
vendored
Normal file
423
app/vendors/frontend/vendors.html
vendored
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
{% 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>Prioritet</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Handlinger</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="vendorsTableBody">
|
||||||
|
<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> 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-md-12">
|
||||||
|
<label class="form-label">Prioritet (1-100)</label>
|
||||||
|
<input type="number" class="form-control" id="priority" value="50" min="1" max="100">
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/vendors?${params}`);
|
||||||
|
const vendors = await response.json();
|
||||||
|
|
||||||
|
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>
|
||||||
|
Kunne ikke indlæse leverandører
|
||||||
|
</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>
|
||||||
|
<div class="priority-badge ${getPriorityClass(vendor.priority)}">
|
||||||
|
${vendor.priority}
|
||||||
|
</div>
|
||||||
|
</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 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 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 searchTimeout;
|
||||||
|
document.getElementById('searchInput').addEventListener('input', (e) => {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
searchTerm = e.target.value;
|
||||||
|
currentPage = 0;
|
||||||
|
loadVendors();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load on page ready
|
||||||
|
document.addEventListener('DOMContentLoaded', loadVendors);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
10
main.py
10
main.py
@ -20,10 +20,15 @@ from app.customers.backend import router as customers_api
|
|||||||
from app.customers.backend import views as customers_views
|
from app.customers.backend import views as customers_views
|
||||||
from app.contacts.backend import router as contacts_api
|
from app.contacts.backend import router as contacts_api
|
||||||
from app.contacts.backend import views as contacts_views
|
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.hardware.backend import router as hardware_api
|
||||||
from app.billing.backend import router as billing_api
|
from app.billing.backend import router as billing_api
|
||||||
from app.system.backend import router as system_api
|
from app.system.backend import router as system_api
|
||||||
from app.dashboard.backend import views as dashboard_views
|
from app.dashboard.backend import views as dashboard_views
|
||||||
|
from app.dashboard.backend import router as dashboard_api
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -83,15 +88,20 @@ app.add_middleware(
|
|||||||
app.include_router(auth_api.router, prefix="/api/v1/auth", tags=["Authentication"])
|
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(customers_api.router, prefix="/api/v1", tags=["Customers"])
|
||||||
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
|
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(hardware_api.router, prefix="/api/v1", tags=["Hardware"])
|
||||||
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
|
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(system_api.router, prefix="/api/v1", tags=["System"])
|
||||||
|
app.include_router(dashboard_api.router, prefix="/api/v1/dashboard", tags=["Dashboard"])
|
||||||
|
|
||||||
# Frontend Routers
|
# Frontend Routers
|
||||||
app.include_router(auth_views.router, tags=["Frontend"])
|
app.include_router(auth_views.router, tags=["Frontend"])
|
||||||
app.include_router(dashboard_views.router, tags=["Frontend"])
|
app.include_router(dashboard_views.router, tags=["Frontend"])
|
||||||
app.include_router(customers_views.router, tags=["Frontend"])
|
app.include_router(customers_views.router, tags=["Frontend"])
|
||||||
app.include_router(contacts_views.router, tags=["Frontend"])
|
app.include_router(contacts_views.router, tags=["Frontend"])
|
||||||
|
app.include_router(vendors_views.router, tags=["Frontend"])
|
||||||
|
app.include_router(settings_views.router, tags=["Frontend"])
|
||||||
|
|
||||||
# Serve static files (UI)
|
# Serve static files (UI)
|
||||||
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
||||||
|
|||||||
75
migrations/006_settings.sql
Normal file
75
migrations/006_settings.sql
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
-- 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';
|
||||||
113
scripts/import_vendors_from_omnisync.py
Normal file
113
scripts/import_vendors_from_omnisync.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
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}")
|
||||||
Loading…
Reference in New Issue
Block a user