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:
Christian 2025-12-06 11:04:19 +01:00
parent 050e886f22
commit 3a35042788
14 changed files with 2232 additions and 76 deletions

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

View File

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

View File

@ -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>
<!-- 3. Vendor Distribution & Quick Links -->
<div class="col-lg-4"> <div class="col-lg-4">
<div class="card p-4 h-100"> <div class="card p-4 mb-4">
<h5 class="fw-bold mb-4">System Status</h5> <h5 class="fw-bold mb-4">Leverandør Fordeling</h5>
<div id="vendorDistribution">
<div class="mb-4"> <div class="text-center py-3">
<div class="d-flex justify-content-between mb-2"> <div class="spinner-border text-primary" role="status"></div>
<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> </div>
</div>
<div class="mb-4"> <!-- 4. Quick Actions / Shortcuts -->
<div class="d-flex justify-content-between mb-2"> <div class="card p-4">
<span class="small fw-bold text-muted">MEMORY</span> <h5 class="fw-bold mb-3">Genveje</h5>
<span class="small fw-bold">56%</span> <div class="d-grid gap-2">
</div> <a href="/settings" class="btn btn-light text-start p-3 d-flex align-items-center">
<div class="progress" style="height: 8px; background-color: var(--accent-light);"> <div class="bg-white p-2 rounded me-3 shadow-sm">
<div class="progress-bar" style="width: 56%; background-color: var(--accent);"></div> <i class="bi bi-gear text-primary"></i>
</div> </div>
</div> <div>
<div class="fw-bold">Indstillinger</div>
<div class="mt-auto p-3 rounded" style="background-color: var(--accent-light);"> <small class="text-muted">Konfigurer systemet</small>
<div class="d-flex"> </div>
<i class="bi bi-check-circle-fill text-success me-2"></i> </a>
<small class="fw-bold" style="color: var(--accent)">Alle systemer kører optimalt.</small> <a href="/vendors" class="btn btn-light text-start p-3 d-flex align-items-center">
</div> <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 %}

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

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

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

View File

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

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

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

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