diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0461fe5..3099459 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -33,6 +33,24 @@ BMC Hub is a central management system for BMC Networks built with **FastAPI + P - Log all external API calls before execution - Provide dry-run mode that logs without executing +## Design System + +### "Nordic Top" Standard +The UI/UX is based on the **Nordic Top** design template found in `docs/design_reference/`. +- **Style**: Minimalist, clean, "Nordic" aesthetic. +- **Layout**: Fluid width, top navigation bar, card-based content. +- **Primary Color**: Deep Blue (`#0f4c75`). + +### Mandatory Features +All frontend implementations MUST support: +1. **Dark Mode**: A toggle to switch between Light and Dark themes. +2. **Color Themes**: Architecture must allow for dynamic accent color changes (CSS Variables). +3. **Responsive Design**: Mobile-first approach using Bootstrap 5 grid. + +### Reference Implementation +- **Components**: See `docs/design_reference/components.html` for the master list of UI elements. +- **Templates**: Use `index.html`, `customers.html`, `form.html` in `docs/design_reference/` as base templates. + ## Development Workflows ### Local Development Setup @@ -132,6 +150,9 @@ if settings.ECONOMIC_READ_ONLY: - For health checks: return dict with `status`, `service`, `version` keys ### File Organization +- **Feature-Based Structure (Vertical Slices)**: Organize code by domain/feature. + - **Pattern**: `//frontend` and `//backend` + - **Example**: `Customers_companies/frontend` (Templates, UI) and `Customers_companies/backend` (Routers, Models) - **One router per domain** - don't create mega-files - **Services in `app/services/`** for business logic (e.g., `economic.py` for API integration) - **Jobs in `app/jobs/`** for scheduled tasks diff --git a/app/auth/backend/router.py b/app/auth/backend/router.py new file mode 100644 index 0000000..a8ed06d --- /dev/null +++ b/app/auth/backend/router.py @@ -0,0 +1,86 @@ +""" +Auth API Router - Login, Logout, Me endpoints +""" +from fastapi import APIRouter, HTTPException, status, Request, Depends +from pydantic import BaseModel +from app.core.auth_service import AuthService +from app.core.auth_dependencies import get_current_user +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: dict + + +class LogoutRequest(BaseModel): + token_jti: str + + +@router.post("/login", response_model=LoginResponse) +async def login(request: Request, credentials: LoginRequest): + """ + Authenticate user and return JWT token + """ + ip_address = request.client.host if request.client else None + + # Authenticate user + user = AuthService.authenticate_user( + username=credentials.username, + password=credentials.password, + ip_address=ip_address + ) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Create access token + access_token = AuthService.create_access_token( + user_id=user['user_id'], + username=user['username'], + is_superadmin=user['is_superadmin'] + ) + + return LoginResponse( + access_token=access_token, + user=user + ) + + +@router.post("/logout") +async def logout(request: LogoutRequest, current_user: dict = Depends(get_current_user)): + """ + Revoke JWT token (logout) + """ + AuthService.revoke_token(request.token_jti, current_user['id']) + + return {"message": "Successfully logged out"} + + +@router.get("/me") +async def get_me(current_user: dict = Depends(get_current_user)): + """ + Get current authenticated user info + """ + return { + "id": current_user['id'], + "username": current_user['username'], + "email": current_user['email'], + "full_name": current_user['full_name'], + "is_superadmin": current_user['is_superadmin'], + "permissions": current_user['permissions'] + } diff --git a/app/auth/backend/views.py b/app/auth/backend/views.py new file mode 100644 index 0000000..c38d17f --- /dev/null +++ b/app/auth/backend/views.py @@ -0,0 +1,20 @@ +""" +Auth Frontend Views - Login page +""" +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +router = APIRouter() +templates = Jinja2Templates(directory="app") + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + """ + Render login page + """ + return templates.TemplateResponse( + "auth/frontend/login.html", + {"request": request} + ) diff --git a/app/auth/frontend/login.html b/app/auth/frontend/login.html new file mode 100644 index 0000000..226a85d --- /dev/null +++ b/app/auth/frontend/login.html @@ -0,0 +1,199 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Login - BMC Hub{% endblock %} + +{% block content %} +
+
+
+
+
+
+

BMC Hub

+

Log ind for at fortsætte

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+ + +
+
+ +
+ + BMC Networks © 2024 + +
+
+
+
+ + + + +{% endblock %} diff --git a/app/routers/billing.py b/app/billing/backend/router.py similarity index 100% rename from app/routers/billing.py rename to app/billing/backend/router.py diff --git a/app/contacts/backend/router.py b/app/contacts/backend/router.py new file mode 100644 index 0000000..51a59b1 --- /dev/null +++ b/app/contacts/backend/router.py @@ -0,0 +1,308 @@ +""" +Contact API Router +Handles contact CRUD operations with multi-company support +""" + +from fastapi import APIRouter, HTTPException, Query +from typing import Optional, List +from app.core.database import execute_query, execute_insert, execute_update +from app.models.schemas import Contact, ContactCreate, ContactUpdate, ContactCompanyLink, CompanyInfo +import logging + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/contacts", response_model=dict) +async def get_contacts( + search: Optional[str] = None, + customer_id: Optional[int] = None, + is_active: Optional[bool] = None, + limit: int = Query(default=20, le=100), + offset: int = Query(default=0, ge=0) +): + """ + Get all contacts with optional filtering, search, and pagination + """ + try: + # Build WHERE clauses + where_clauses = [] + params = [] + + if search: + where_clauses.append("(c.first_name ILIKE %s OR c.last_name ILIKE %s OR c.email ILIKE %s)") + params.extend([f"%{search}%", f"%{search}%", f"%{search}%"]) + + if is_active is not None: + where_clauses.append("c.is_active = %s") + params.append(is_active) + + if customer_id is not None: + where_clauses.append("EXISTS (SELECT 1 FROM contact_companies cc WHERE cc.contact_id = c.id AND cc.customer_id = %s)") + params.append(customer_id) + + where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" + + # Count total + count_query = f""" + SELECT COUNT(DISTINCT c.id) + FROM contacts c + {where_sql} + """ + count_result = execute_query(count_query, tuple(params), fetchone=True) + total = count_result['count'] if count_result else 0 + + # Get contacts with company count + query = f""" + SELECT + c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, + c.title, c.department, c.is_active, c.vtiger_id, + c.created_at, c.updated_at, + COUNT(DISTINCT cc.customer_id) as company_count, + ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names + FROM contacts c + LEFT JOIN contact_companies cc ON c.id = cc.contact_id + LEFT JOIN customers cu ON cc.customer_id = cu.id + {where_sql} + GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, + c.title, c.department, c.is_active, c.vtiger_id, c.created_at, c.updated_at + ORDER BY c.last_name, c.first_name + LIMIT %s OFFSET %s + """ + params.extend([limit, offset]) + + contacts = execute_query(query, tuple(params)) # Default is fetchall + + return { + "contacts": contacts or [], + "total": total, + "limit": limit, + "offset": offset + } + + except Exception as e: + logger.error(f"Failed to get contacts: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/contacts/{contact_id}", response_model=dict) +async def get_contact(contact_id: int): + """ + Get a single contact with all linked companies + """ + try: + # Get contact + contact_query = """ + SELECT id, first_name, last_name, email, phone, mobile, + title, department, is_active, vtiger_id, + created_at, updated_at + FROM contacts + WHERE id = %s + """ + contact = execute_query(contact_query, (contact_id,), fetchone=True) + + if not contact: + raise HTTPException(status_code=404, detail="Contact not found") + + # Get linked companies + companies_query = """ + SELECT + cu.id, cu.name, + cc.is_primary, cc.role, cc.notes + FROM contact_companies cc + JOIN customers cu ON cc.customer_id = cu.id + WHERE cc.contact_id = %s + ORDER BY cc.is_primary DESC, cu.name + """ + companies = execute_query(companies_query, (contact_id,)) # Default is fetchall + + contact['companies'] = companies or [] + return contact + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get contact {contact_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/contacts", response_model=dict) +async def create_contact(contact: ContactCreate): + """ + Create a new contact and link to companies + """ + try: + # Insert contact + insert_query = """ + INSERT INTO contacts (first_name, last_name, email, phone, mobile, title, department, is_active) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """ + contact_id = execute_insert( + insert_query, + (contact.first_name, contact.last_name, contact.email, contact.phone, + contact.mobile, contact.title, contact.department, contact.is_active) + ) + + # Link to companies + if contact.company_ids: + for idx, customer_id in enumerate(contact.company_ids): + is_primary = idx == 0 and contact.is_primary + link_query = """ + INSERT INTO contact_companies (contact_id, customer_id, is_primary, role, notes) + VALUES (%s, %s, %s, %s, %s) + """ + execute_insert( + link_query, + (contact_id, customer_id, is_primary, contact.role, contact.notes) + ) + + # Return created contact + return await get_contact(contact_id) + + except Exception as e: + logger.error(f"Failed to create contact: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/contacts/{contact_id}", response_model=dict) +async def update_contact(contact_id: int, contact: ContactUpdate): + """ + Update a contact + """ + try: + # Check if contact exists + existing = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,), fetchone=True) + if not existing: + raise HTTPException(status_code=404, detail="Contact not found") + + # Build update query + update_fields = [] + params = [] + + if contact.first_name is not None: + update_fields.append("first_name = %s") + params.append(contact.first_name) + + if contact.last_name is not None: + update_fields.append("last_name = %s") + params.append(contact.last_name) + + if contact.email is not None: + update_fields.append("email = %s") + params.append(contact.email) + + if contact.phone is not None: + update_fields.append("phone = %s") + params.append(contact.phone) + + if contact.mobile is not None: + update_fields.append("mobile = %s") + params.append(contact.mobile) + + if contact.title is not None: + update_fields.append("title = %s") + params.append(contact.title) + + if contact.department is not None: + update_fields.append("department = %s") + params.append(contact.department) + + if contact.is_active is not None: + update_fields.append("is_active = %s") + params.append(contact.is_active) + + if not update_fields: + return await get_contact(contact_id) + + update_query = f""" + UPDATE contacts + SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """ + params.append(contact_id) + + execute_update(update_query, tuple(params)) + + return await get_contact(contact_id) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to update contact {contact_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/contacts/{contact_id}") +async def delete_contact(contact_id: int): + """ + Delete a contact (cascade deletes company links) + """ + try: + result = execute_update("DELETE FROM contacts WHERE id = %s", (contact_id,)) + if result == 0: + raise HTTPException(status_code=404, detail="Contact not found") + + return {"message": "Contact deleted successfully"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to delete contact {contact_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/contacts/{contact_id}/companies") +async def link_contact_to_company(contact_id: int, link: ContactCompanyLink): + """ + Link a contact to a company + """ + try: + # Check if contact exists + contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,), fetchone=True) + if not contact: + raise HTTPException(status_code=404, detail="Contact not found") + + # Check if company exists + customer = execute_query("SELECT id FROM customers WHERE id = %s", (link.customer_id,), fetchone=True) + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + # Insert link (ON CONFLICT updates) + query = """ + INSERT INTO contact_companies (contact_id, customer_id, is_primary, role, notes) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (contact_id, customer_id) + DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role, notes = EXCLUDED.notes + """ + execute_insert(query, (contact_id, link.customer_id, link.is_primary, link.role, link.notes)) + + return {"message": "Contact linked to company successfully"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to link contact to company: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/contacts/{contact_id}/companies/{customer_id}") +async def unlink_contact_from_company(contact_id: int, customer_id: int): + """ + Unlink a contact from a company + """ + try: + result = execute_update( + "DELETE FROM contact_companies WHERE contact_id = %s AND customer_id = %s", + (contact_id, customer_id) + ) + + if result == 0: + raise HTTPException(status_code=404, detail="Link not found") + + return {"message": "Contact unlinked from company successfully"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to unlink contact from company: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/contacts/backend/views.py b/app/contacts/backend/views.py new file mode 100644 index 0000000..2b9966d --- /dev/null +++ b/app/contacts/backend/views.py @@ -0,0 +1,28 @@ +""" +Contact view routes for rendering HTML pages +""" +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +router = APIRouter() +templates = Jinja2Templates(directory="app") + + +@router.get("/contacts", response_class=HTMLResponse) +async def contacts_page(request: Request): + """ + Render the contacts list page + """ + return templates.TemplateResponse("contacts/frontend/contacts.html", {"request": request}) + + +@router.get("/contacts/{contact_id}", response_class=HTMLResponse) +async def contact_detail_page(request: Request, contact_id: int): + """ + Render the contact detail page + """ + return templates.TemplateResponse("contacts/frontend/contact_detail.html", { + "request": request, + "contact_id": contact_id + }) diff --git a/app/contacts/frontend/contact_detail.html b/app/contacts/frontend/contact_detail.html new file mode 100644 index 0000000..906022a --- /dev/null +++ b/app/contacts/frontend/contact_detail.html @@ -0,0 +1,516 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Kontakt Detaljer - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + +
+
+
+
?
+
+

Loading...

+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+ + +
+ +
+ +
+ +
+
+
+
+
Kontakt Oplysninger
+
+ Navn + - +
+
+ Email + - +
+
+ Telefon + - +
+
+ Mobil + - +
+
+
+ +
+
+
Rolle & Stilling
+
+ Titel + - +
+
+ Afdeling + - +
+
+ Status + - +
+
+ Antal Firmaer + - +
+
+
+ +
+
+
System Info
+
+
+
+ vTiger ID + - +
+
+
+
+ Oprettet + - +
+
+
+
+
+
+
+ + +
+
+
Tilknyttede Firmaer
+ +
+
+
+
+
+
+
+
+
+
+ + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/contacts/frontend/contacts.html b/app/contacts/frontend/contacts.html new file mode 100644 index 0000000..0045c20 --- /dev/null +++ b/app/contacts/frontend/contacts.html @@ -0,0 +1,483 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Kontakter - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

Kontakter

+

Administrer kontaktpersoner

+
+
+ + +
+
+ +
+ + + +
+ +
+
+ + + + + + + + + + + + + + + + +
NavnKontakt InfoTitelFirmaerStatusHandlinger
+
+ Loading... +
+
+
+ + +
+
+ Viser 0-0 af 0 kontakter +
+
+ + +
+
+
+ + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/core/auth_dependencies.py b/app/core/auth_dependencies.py new file mode 100644 index 0000000..7bc1031 --- /dev/null +++ b/app/core/auth_dependencies.py @@ -0,0 +1,220 @@ +""" +FastAPI dependencies for authentication and authorization +Adapted from OmniSync for BMC Hub +""" +from fastapi import Depends, HTTPException, status, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from typing import Optional +from app.core.auth_service import AuthService +import logging + +logger = logging.getLogger(__name__) + +security = HTTPBearer() + + +async def get_current_user( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security) +) -> dict: + """ + Dependency to get current authenticated user from JWT token + + Usage: + @router.get("/endpoint") + async def my_endpoint(current_user: dict = Depends(get_current_user)): + ... + """ + token = credentials.credentials + + # Verify token + payload = AuthService.verify_token(token) + + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Get user ID + user_id = int(payload.get("sub")) + username = payload.get("username") + is_superadmin = payload.get("is_superadmin", False) + + # Add IP address to user info + ip_address = request.client.host if request.client else None + + # Get additional user details from database + from app.core.database import execute_query + user_details = execute_query( + "SELECT email, full_name FROM users WHERE id = %s", + (user_id,), + fetchone=True + ) + + return { + "id": user_id, + "username": username, + "email": user_details.get('email') if user_details else None, + "full_name": user_details.get('full_name') if user_details else None, + "is_superadmin": is_superadmin, + "ip_address": ip_address, + "permissions": AuthService.get_user_permissions(user_id) + } + + +async def get_optional_user( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> Optional[dict]: + """ + Dependency to get current user if authenticated, None otherwise + Allows endpoints that work both with and without authentication + """ + if not credentials: + return None + + try: + return await get_current_user(request, credentials) + except HTTPException: + return None + + +def require_permission(permission: str): + """ + Dependency factory to require specific permission + + Usage: + @router.post("/products", dependencies=[Depends(require_permission("products.create"))]) + async def create_product(...): + ... + + Or with user access: + @router.post("/products") + async def create_product( + current_user: dict = Depends(require_permission("products.create")) + ): + ... + """ + async def permission_checker(current_user: dict = Depends(get_current_user)) -> dict: + user_id = current_user["id"] + username = current_user["username"] + + # Superadmins have all permissions + if current_user.get("is_superadmin"): + return current_user + + # Check permission + if not AuthService.user_has_permission(user_id, permission): + logger.warning( + f"⚠️ Permission denied: {username} attempted {permission}" + ) + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permission: {permission}" + ) + + return current_user + + return permission_checker + + +def require_superadmin(current_user: dict = Depends(get_current_user)) -> dict: + """ + Dependency to require superadmin access + + Usage: + @router.post("/admin/users") + async def create_user(current_user: dict = Depends(require_superadmin)): + ... + """ + if not current_user.get("is_superadmin"): + logger.warning( + f"⚠️ Superadmin required: {current_user['username']} attempted admin access" + ) + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Superadmin access required" + ) + + return current_user + + +def require_any_permission(*permissions: str): + """ + Dependency factory to require ANY of the specified permissions + + Usage: + @router.get("/reports") + async def get_reports( + current_user: dict = Depends(require_any_permission("reports.view", "reports.admin")) + ): + ... + """ + async def permission_checker(current_user: dict = Depends(get_current_user)) -> dict: + user_id = current_user["id"] + + # Superadmins have all permissions + if current_user.get("is_superadmin"): + return current_user + + # Check if user has ANY of the permissions + for permission in permissions: + if AuthService.user_has_permission(user_id, permission): + return current_user + + # None of the permissions matched + logger.warning( + f"⚠️ Permission denied: {current_user['username']} " + f"attempted one of: {', '.join(permissions)}" + ) + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permission. Need one of: {', '.join(permissions)}" + ) + + return permission_checker + + +def require_all_permissions(*permissions: str): + """ + Dependency factory to require ALL of the specified permissions + + Usage: + @router.post("/sensitive-operation") + async def sensitive_op( + current_user: dict = Depends(require_all_permissions("admin.access", "data.export")) + ): + ... + """ + async def permission_checker(current_user: dict = Depends(get_current_user)) -> dict: + user_id = current_user["id"] + + # Superadmins have all permissions + if current_user.get("is_superadmin"): + return current_user + + # Check if user has ALL permissions + missing_permissions = [] + for permission in permissions: + if not AuthService.user_has_permission(user_id, permission): + missing_permissions.append(permission) + + if missing_permissions: + logger.warning( + f"⚠️ Permission denied: {current_user['username']} " + f"missing: {', '.join(missing_permissions)}" + ) + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permissions: {', '.join(missing_permissions)}" + ) + + return current_user + + return permission_checker diff --git a/app/core/auth_service.py b/app/core/auth_service.py new file mode 100644 index 0000000..eaf3054 --- /dev/null +++ b/app/core/auth_service.py @@ -0,0 +1,313 @@ +""" +Authentication Service - Håndterer login, JWT tokens, password hashing +Adapted from OmniSync for BMC Hub +""" +from typing import Optional, Dict, List +from datetime import datetime, timedelta +import hashlib +import secrets +import jwt +from app.core.database import execute_query, execute_insert, execute_update +from app.core.config import settings +import logging + +logger = logging.getLogger(__name__) + +# JWT Settings +SECRET_KEY = getattr(settings, 'JWT_SECRET_KEY', 'your-secret-key-change-in-production') +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer + + +class AuthService: + """Service for authentication and authorization""" + + @staticmethod + def hash_password(password: str) -> str: + """ + Hash password using SHA256 + I produktion: Brug bcrypt eller argon2! + """ + return hashlib.sha256(password.encode()).hexdigest() + + @staticmethod + def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify password against hash""" + return AuthService.hash_password(plain_password) == hashed_password + + @staticmethod + def create_access_token(user_id: int, username: str, is_superadmin: bool = False) -> str: + """ + Create JWT access token + + Args: + user_id: User ID + username: Username + is_superadmin: Whether user is superadmin + + Returns: + JWT token string + """ + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + jti = secrets.token_urlsafe(32) # JWT ID for token revocation + + payload = { + "sub": str(user_id), + "username": username, + "is_superadmin": is_superadmin, + "exp": expire, + "iat": datetime.utcnow(), + "jti": jti + } + + token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + + # Store session for token revocation + execute_insert( + """INSERT INTO sessions (user_id, token_jti, expires_at) + VALUES (%s, %s, %s)""", + (user_id, jti, expire) + ) + + return token + + @staticmethod + def verify_token(token: str) -> Optional[Dict]: + """ + Verify and decode JWT token + + Returns: + Dict with user info or None if invalid + """ + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + + # Check if token is revoked + jti = payload.get('jti') + if jti: + session = execute_query( + "SELECT revoked FROM sessions WHERE token_jti = %s", + (jti,), + fetchone=True + ) + if session and session.get('revoked'): + logger.warning(f"⚠️ Revoked token used: {jti[:10]}...") + return None + + return payload + + except jwt.ExpiredSignatureError: + logger.warning("⚠️ Expired token") + return None + except jwt.InvalidTokenError as e: + logger.warning(f"⚠️ Invalid token: {e}") + return None + + @staticmethod + def authenticate_user(username: str, password: str, ip_address: Optional[str] = None) -> Optional[Dict]: + """ + Authenticate user with username/password + + Args: + username: Username + password: Plain text password + ip_address: Client IP address (for logging) + + Returns: + User dict if successful, None otherwise + """ + # Get user + user = execute_query( + """SELECT id, username, email, password_hash, full_name, + is_active, is_superadmin, failed_login_attempts, locked_until + FROM users + WHERE username = %s OR email = %s""", + (username, username), + fetchone=True + ) + + if not user: + logger.warning(f"❌ Login failed: User not found - {username}") + return None + + # Check if account is active + if not user['is_active']: + logger.warning(f"❌ Login failed: Account disabled - {username}") + return None + + # Check if account is locked + if user['locked_until']: + locked_until = user['locked_until'] + if datetime.now() < locked_until: + logger.warning(f"❌ Login failed: Account locked - {username}") + return None + else: + # Unlock account + execute_update( + "UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE id = %s", + (user['id'],) + ) + + # Verify password + if not AuthService.verify_password(password, user['password_hash']): + # Increment failed attempts + failed_attempts = user['failed_login_attempts'] + 1 + + if failed_attempts >= 5: + # Lock account for 30 minutes + locked_until = datetime.now() + timedelta(minutes=30) + execute_update( + """UPDATE users + SET failed_login_attempts = %s, locked_until = %s + WHERE id = %s""", + (failed_attempts, locked_until, user['id']) + ) + logger.warning(f"🔒 Account locked due to failed attempts: {username}") + else: + execute_update( + "UPDATE users SET failed_login_attempts = %s WHERE id = %s", + (failed_attempts, user['id']) + ) + + logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})") + return None + + # Success! Reset failed attempts and update last login + execute_update( + """UPDATE users + SET failed_login_attempts = 0, + locked_until = NULL, + last_login_at = CURRENT_TIMESTAMP + WHERE id = %s""", + (user['id'],) + ) + + logger.info(f"✅ User logged in: {username} from IP: {ip_address}") + + return { + 'user_id': user['id'], + 'username': user['username'], + 'email': user['email'], + 'full_name': user['full_name'], + 'is_superadmin': bool(user['is_superadmin']) + } + + @staticmethod + def revoke_token(jti: str, user_id: int): + """Revoke a JWT token""" + execute_update( + "UPDATE sessions SET revoked = TRUE WHERE token_jti = %s AND user_id = %s", + (jti, user_id) + ) + logger.info(f"🔒 Token revoked for user {user_id}") + + @staticmethod + def get_user_permissions(user_id: int) -> List[str]: + """ + Get all permissions for a user (through their groups) + + Args: + user_id: User ID + + Returns: + List of permission codes + """ + # Check if user is superadmin first + user = execute_query( + "SELECT is_superadmin FROM users WHERE id = %s", + (user_id,), + fetchone=True + ) + + # Superadmins have all permissions + if user and user['is_superadmin']: + all_perms = execute_query("SELECT code FROM permissions") + return [p['code'] for p in all_perms] if all_perms else [] + + # Get permissions through groups + perms = execute_query(""" + SELECT DISTINCT p.code + FROM permissions p + JOIN group_permissions gp ON p.id = gp.permission_id + JOIN user_groups ug ON gp.group_id = ug.group_id + WHERE ug.user_id = %s + """, (user_id,)) + + return [p['code'] for p in perms] if perms else [] + + @staticmethod + def user_has_permission(user_id: int, permission_code: str) -> bool: + """ + Check if user has specific permission + + Args: + user_id: User ID + permission_code: Permission code (e.g., 'customers.view') + + Returns: + True if user has permission + """ + # Superadmins have all permissions + user = execute_query( + "SELECT is_superadmin FROM users WHERE id = %s", + (user_id,), + fetchone=True + ) + + if user and user['is_superadmin']: + return True + + # Check if user has permission through groups + result = execute_query(""" + SELECT COUNT(*) as cnt + FROM permissions p + JOIN group_permissions gp ON p.id = gp.permission_id + JOIN user_groups ug ON gp.group_id = ug.group_id + WHERE ug.user_id = %s AND p.code = %s + """, (user_id, permission_code), fetchone=True) + + return bool(result and result['cnt'] > 0) + + @staticmethod + def create_user( + username: str, + email: str, + password: str, + full_name: Optional[str] = None, + is_superadmin: bool = False + ) -> Optional[int]: + """ + Create a new user + + Returns: + New user ID or None if failed + """ + password_hash = AuthService.hash_password(password) + + user_id = execute_insert( + """INSERT INTO users + (username, email, password_hash, full_name, is_superadmin) + VALUES (%s, %s, %s, %s, %s) RETURNING id""", + (username, email, password_hash, full_name, is_superadmin) + ) + + logger.info(f"👤 User created: {username} (ID: {user_id})") + return user_id + + @staticmethod + def change_password(user_id: int, new_password: str): + """Change user password""" + password_hash = AuthService.hash_password(new_password) + + execute_update( + "UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", + (password_hash, user_id) + ) + + # Revoke all existing sessions + execute_update( + "UPDATE sessions SET revoked = TRUE WHERE user_id = %s", + (user_id,) + ) + + logger.info(f"🔑 Password changed for user {user_id}") diff --git a/app/core/database.py b/app/core/database.py index ba06d42..e8c22c8 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -55,19 +55,94 @@ def get_db(): release_db_connection(conn) -def execute_query(query: str, params: tuple = None, fetch: bool = True): - """Execute a SQL query and return results""" +def execute_query(query: str, params: tuple = None, fetchone: bool = False): + """ + Execute a SQL query and return results + + Args: + query: SQL query string + params: Query parameters tuple + fetchone: If True, return single row dict, otherwise list of dicts + + Returns: + Single dict if fetchone=True, otherwise list of dicts + """ conn = get_db_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cursor: - cursor.execute(query, params) - if fetch: - return cursor.fetchall() - conn.commit() - return cursor.rowcount + cursor.execute(query, params or ()) + if fetchone: + row = cursor.fetchone() + return dict(row) if row else None + else: + rows = cursor.fetchall() + return [dict(row) for row in rows] except Exception as e: conn.rollback() logger.error(f"Query error: {e}") raise finally: release_db_connection(conn) + + +def execute_insert(query: str, params: tuple = ()) -> Optional[int]: + """ + Execute an INSERT query and return last row id + + Args: + query: SQL INSERT query (will add RETURNING id if not present) + params: Query parameters tuple + + Returns: + Last inserted row ID or None + """ + conn = get_db_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + # PostgreSQL requires RETURNING clause + if "RETURNING" not in query.upper(): + query = query.rstrip(";") + " RETURNING id" + cursor.execute(query, params) + result = cursor.fetchone() + conn.commit() + + # If result exists, return the first column value (typically ID) + if result: + # If it's a dict, get first value + if isinstance(result, dict): + return list(result.values())[0] + # If it's a tuple/list, get first element + return result[0] + return None + except Exception as e: + conn.rollback() + logger.error(f"Insert error: {e}") + raise + finally: + release_db_connection(conn) + + +def execute_update(query: str, params: tuple = ()) -> int: + """ + Execute an UPDATE/DELETE query and return affected rows + + Args: + query: SQL UPDATE/DELETE query + params: Query parameters tuple + + Returns: + Number of affected rows + """ + conn = get_db_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute(query, params) + rowcount = cursor.rowcount + conn.commit() + return rowcount + except Exception as e: + conn.rollback() + logger.error(f"Update error: {e}") + raise + finally: + release_db_connection(conn) diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py new file mode 100644 index 0000000..02d47f4 --- /dev/null +++ b/app/customers/backend/router.py @@ -0,0 +1,357 @@ +""" +Customers Router +API endpoints for customer management +Adapted from OmniSync for BMC Hub +""" + +from fastapi import APIRouter, HTTPException, Query +from typing import List, Optional, Dict +from pydantic import BaseModel +import logging + +from app.core.database import execute_query, execute_insert, execute_update +from app.services.cvr_service import get_cvr_service + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# Pydantic Models +class CustomerBase(BaseModel): + name: str + cvr_number: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + city: Optional[str] = None + postal_code: Optional[str] = None + country: Optional[str] = "DK" + website: Optional[str] = None + is_active: Optional[bool] = True + invoice_email: Optional[str] = None + mobile_phone: Optional[str] = None + + +class CustomerCreate(CustomerBase): + pass + + +class CustomerUpdate(BaseModel): + name: Optional[str] = None + cvr_number: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + city: Optional[str] = None + postal_code: Optional[str] = None + country: Optional[str] = None + website: Optional[str] = None + is_active: Optional[bool] = None + invoice_email: Optional[str] = None + mobile_phone: Optional[str] = None + + +class ContactCreate(BaseModel): + first_name: str + last_name: str + email: Optional[str] = None + phone: Optional[str] = None + mobile: Optional[str] = None + title: Optional[str] = None + department: Optional[str] = None + is_primary: Optional[bool] = False + role: Optional[str] = None + + +@router.get("/customers") +async def list_customers( + limit: int = Query(default=50, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + search: Optional[str] = Query(default=None), + source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None + is_active: Optional[bool] = Query(default=None) +): + """ + List customers with pagination and filtering + + Args: + limit: Maximum number of customers to return + offset: Number of customers to skip + search: Search term for name, email, cvr, phone, city + source: Filter by source ('vtiger' or 'local') + is_active: Filter by active status + """ + # Build query + query = """ + SELECT + c.*, + COUNT(DISTINCT cc.contact_id) as contact_count + FROM customers c + LEFT JOIN contact_companies cc ON cc.customer_id = c.id + WHERE 1=1 + """ + params = [] + + # Add search filter + if search: + query += """ AND ( + c.name ILIKE %s OR + c.email ILIKE %s OR + c.cvr_number ILIKE %s OR + c.phone ILIKE %s OR + c.city ILIKE %s + )""" + search_term = f"%{search}%" + params.extend([search_term] * 5) + + # Add source filter + if source == 'vtiger': + query += " AND c.vtiger_id IS NOT NULL" + elif source == 'local': + query += " AND c.vtiger_id IS NULL" + + # Add active filter + if is_active is not None: + query += " AND c.is_active = %s" + params.append(is_active) + + query += """ + GROUP BY c.id + ORDER BY c.name + LIMIT %s OFFSET %s + """ + params.extend([limit, offset]) + + rows = execute_query(query, tuple(params)) + + # Get total count + count_query = "SELECT COUNT(*) as total FROM customers WHERE 1=1" + count_params = [] + + if search: + count_query += """ AND ( + name ILIKE %s OR + email ILIKE %s OR + cvr_number ILIKE %s OR + phone ILIKE %s OR + city ILIKE %s + )""" + count_params.extend([search_term] * 5) + + if source == 'vtiger': + count_query += " AND vtiger_id IS NOT NULL" + elif source == 'local': + count_query += " AND vtiger_id IS NULL" + + if is_active is not None: + count_query += " AND is_active = %s" + count_params.append(is_active) + + count_result = execute_query(count_query, tuple(count_params), fetchone=True) + total = count_result['total'] if count_result else 0 + + return { + "customers": rows or [], + "total": total, + "limit": limit, + "offset": offset + } + + +@router.get("/customers/{customer_id}") +async def get_customer(customer_id: int): + """Get single customer by ID with contact count""" + # Get customer + customer = execute_query( + "SELECT * FROM customers WHERE id = %s", + (customer_id,), + fetchone=True + ) + + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + # Get contact count + contact_count_result = execute_query( + "SELECT COUNT(*) as count FROM contact_companies WHERE customer_id = %s", + (customer_id,), + fetchone=True + ) + + contact_count = contact_count_result['count'] if contact_count_result else 0 + + return { + **customer, + 'contact_count': contact_count + } + + +@router.post("/customers") +async def create_customer(customer: CustomerCreate): + """Create a new customer""" + try: + customer_id = execute_insert( + """INSERT INTO customers + (name, cvr_number, email, phone, address, city, postal_code, + country, website, is_active, invoice_email, mobile_phone) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id""", + ( + customer.name, + customer.cvr_number, + customer.email, + customer.phone, + customer.address, + customer.city, + customer.postal_code, + customer.country, + customer.website, + customer.is_active, + customer.invoice_email, + customer.mobile_phone + ) + ) + + logger.info(f"✅ Created customer {customer_id}: {customer.name}") + + # Fetch and return created customer + created = execute_query( + "SELECT * FROM customers WHERE id = %s", + (customer_id,), + fetchone=True + ) + return created + + except Exception as e: + logger.error(f"❌ Failed to create customer: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/customers/{customer_id}") +async def update_customer(customer_id: int, update: CustomerUpdate): + """Update customer information""" + # Verify customer exists + existing = execute_query( + "SELECT id FROM customers WHERE id = %s", + (customer_id,), + fetchone=True + ) + if not existing: + raise HTTPException(status_code=404, detail="Customer not found") + + # Build dynamic UPDATE query + updates = [] + params = [] + + update_dict = update.dict(exclude_unset=True) + for field, value in update_dict.items(): + updates.append(f"{field} = %s") + params.append(value) + + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + params.append(customer_id) + + query = f"UPDATE customers SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s" + + try: + execute_update(query, tuple(params)) + logger.info(f"✅ Updated customer {customer_id}") + + # Fetch and return updated customer + updated = execute_query( + "SELECT * FROM customers WHERE id = %s", + (customer_id,), + fetchone=True + ) + return updated + + except Exception as e: + logger.error(f"❌ Failed to update customer {customer_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/customers/{customer_id}/contacts") +async def get_customer_contacts(customer_id: int): + """Get all contacts for a specific customer""" + rows = execute_query(""" + SELECT + c.*, + cc.is_primary, + cc.role, + cc.notes + FROM contacts c + JOIN contact_companies cc ON c.id = cc.contact_id + WHERE cc.customer_id = %s AND c.is_active = TRUE + ORDER BY cc.is_primary DESC, c.first_name, c.last_name + """, (customer_id,)) + + return rows or [] + + +@router.post("/customers/{customer_id}/contacts") +async def create_customer_contact(customer_id: int, contact: ContactCreate): + """Create a new contact for a customer""" + # Verify customer exists + customer = execute_query( + "SELECT id FROM customers WHERE id = %s", + (customer_id,), + fetchone=True + ) + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + try: + # Create contact + contact_id = execute_insert( + """INSERT INTO contacts + (first_name, last_name, email, phone, mobile, title, department) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id""", + ( + contact.first_name, + contact.last_name, + contact.email, + contact.phone, + contact.mobile, + contact.title, + contact.department + ) + ) + + # Link contact to customer + execute_insert( + """INSERT INTO contact_companies + (contact_id, customer_id, is_primary, role) + VALUES (%s, %s, %s, %s)""", + (contact_id, customer_id, contact.is_primary, contact.role) + ) + + logger.info(f"✅ Created contact {contact_id} for customer {customer_id}") + + # Fetch and return created contact + created = execute_query( + "SELECT * FROM contacts WHERE id = %s", + (contact_id,), + fetchone=True + ) + return created + + except Exception as e: + logger.error(f"❌ Failed to create contact: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/cvr/{cvr_number}") +async def lookup_cvr(cvr_number: str): + """Lookup company information by CVR number""" + cvr_service = get_cvr_service() + + result = await cvr_service.lookup_by_cvr(cvr_number) + + if not result: + raise HTTPException(status_code=404, detail="CVR number not found") + + return result diff --git a/app/customers/backend/views.py b/app/customers/backend/views.py new file mode 100644 index 0000000..d7026cd --- /dev/null +++ b/app/customers/backend/views.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates +from fastapi.responses import HTMLResponse + +router = APIRouter() +templates = Jinja2Templates(directory="app") + +@router.get("/customers", response_class=HTMLResponse) +async def customers_page(request: Request): + """ + Render the customers list page + """ + return templates.TemplateResponse("customers/frontend/customers.html", {"request": request}) + + +@router.get("/customers/{customer_id}", response_class=HTMLResponse) +async def customer_detail_page(request: Request, customer_id: int): + """ + Render the customer detail page + """ + return templates.TemplateResponse("customers/frontend/customer_detail.html", { + "request": request, + "customer_id": customer_id + }) diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html new file mode 100644 index 0000000..46a4c00 --- /dev/null +++ b/app/customers/frontend/customer_detail.html @@ -0,0 +1,494 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Kunde Detaljer - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + +
+
+
+
?
+
+

Loading...

+
+ + + +
+
+
+
+ + +
+
+
+ + +
+ + +
+ +
+ +
+
+
+
+
Virksomhedsoplysninger
+
+ CVR-nummer + - +
+
+ Adresse + - +
+
+ Postnummer & By + - +
+
+ Email + - +
+
+ Telefon + - +
+
+ Hjemmeside + - +
+
+
+ +
+
+
Økonomiske Oplysninger
+
+ e-conomic Kundenr. + - +
+
+ Betalingsbetingelser + - +
+
+ Moms Zone + - +
+
+ Valuta + - +
+
+ EAN-nummer + - +
+
+ Spærret + - +
+
+
+ +
+
+
Integration
+
+
+
+ vTiger ID + - +
+
+ vTiger Sidst Synkroniseret + - +
+
+
+
+ e-conomic Sidst Synkroniseret + - +
+
+ Oprettet + - +
+
+
+
+
+
+
+ + +
+
+
Kontaktpersoner
+ +
+
+
+
+
+
+
+ + +
+
Fakturaer
+
+ Fakturamodul kommer snart... +
+
+ + +
+
Hardware
+
+ Hardwaremodul kommer snart... +
+
+ + +
+
Aktivitet
+
+
+
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/customers/frontend/customers.html b/app/customers/frontend/customers.html new file mode 100644 index 0000000..7985aed --- /dev/null +++ b/app/customers/frontend/customers.html @@ -0,0 +1,502 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Kunder - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

Kunder

+

Administrer dine kunder

+
+
+ + +
+
+ +
+ + + + + +
+ +
+
+ + + + + + + + + + + + + + + + + +
VirksomhedKontakt InfoCVRKildeStatusKontakterHandlinger
+
+ Loading... +
+
+
+ + +
+
+ Viser 0-0 af 0 kunder +
+
+ + +
+
+
+ + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/dashboard/backend/views.py b/app/dashboard/backend/views.py new file mode 100644 index 0000000..5d66642 --- /dev/null +++ b/app/dashboard/backend/views.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates +from fastapi.responses import HTMLResponse + +router = APIRouter() +templates = Jinja2Templates(directory="app") + +@router.get("/", response_class=HTMLResponse) +async def dashboard(request: Request): + """ + Render the dashboard page + """ + return templates.TemplateResponse("dashboard/frontend/index.html", {"request": request}) diff --git a/app/dashboard/frontend/index.html b/app/dashboard/frontend/index.html new file mode 100644 index 0000000..14525b9 --- /dev/null +++ b/app/dashboard/frontend/index.html @@ -0,0 +1,131 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Dashboard - BMC Hub{% endblock %} + +{% block content %} +
+
+

Dashboard

+

Velkommen tilbage, Christian

+
+
+ + +
+
+ +
+
+
+
+

Aktive Kunder

+ +
+

124

+ 12% denne måned +
+
+
+
+
+

Hardware

+ +
+

856

+ Enheder online +
+
+
+
+
+

Support

+ +
+

12

+ 3 kræver handling +
+
+
+
+
+

Omsætning

+ +
+

450k

+ Over budget +
+
+
+ +
+
+
+
Seneste Aktiviteter
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KundeHandlingStatusTid
Advokatgruppen A/SFirewall konfigurationFuldført10:23
Byg & Bo ApSLicens fornyelseAfventerI går
Cafe MøllerNetværksnedbrudKritiskI går
+
+
+
+
+
+
System Status
+ +
+
+ CPU LOAD + 24% +
+
+
+
+
+ +
+
+ MEMORY + 56% +
+
+
+
+
+ +
+
+ + Alle systemer kører optimalt. +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/routers/hardware.py b/app/hardware/backend/router.py similarity index 100% rename from app/routers/hardware.py rename to app/hardware/backend/router.py diff --git a/app/models/schemas.py b/app/models/schemas.py index 7ee6133..e9a0ca5 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -3,10 +3,11 @@ Pydantic Models and Schemas """ from pydantic import BaseModel -from typing import Optional +from typing import Optional, List from datetime import datetime +# Customer Schemas class CustomerBase(BaseModel): """Base customer schema""" name: str @@ -15,9 +16,30 @@ class CustomerBase(BaseModel): address: Optional[str] = None -class CustomerCreate(CustomerBase): +class CustomerCreate(BaseModel): """Schema for creating a customer""" - pass + name: str + cvr_number: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + postal_code: Optional[str] = None + city: Optional[str] = None + website: Optional[str] = None + is_active: bool = True + + +class CustomerUpdate(BaseModel): + """Schema for updating a customer""" + name: Optional[str] = None + cvr_number: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + postal_code: Optional[str] = None + city: Optional[str] = None + website: Optional[str] = None + is_active: Optional[bool] = None class Customer(CustomerBase): @@ -30,6 +52,70 @@ class Customer(CustomerBase): from_attributes = True +# Contact Schemas +class ContactBase(BaseModel): + """Base contact schema""" + first_name: str + last_name: str + email: Optional[str] = None + phone: Optional[str] = None + mobile: Optional[str] = None + title: Optional[str] = None + department: Optional[str] = None + + +class ContactCreate(ContactBase): + """Schema for creating a contact""" + company_ids: List[int] = [] # List of customer IDs to link to + is_primary: bool = False # Whether this is the primary contact for first company + role: Optional[str] = None + notes: Optional[str] = None + is_active: bool = True + + +class ContactUpdate(BaseModel): + """Schema for updating a contact""" + first_name: Optional[str] = None + last_name: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + mobile: Optional[str] = None + title: Optional[str] = None + department: Optional[str] = None + is_active: Optional[bool] = None + + +class ContactCompanyLink(BaseModel): + """Schema for linking/unlinking a contact to a company""" + customer_id: int + is_primary: bool = False + role: Optional[str] = None + notes: Optional[str] = None + + +class CompanyInfo(BaseModel): + """Schema for company information in contact context""" + id: int + name: str + is_primary: bool + role: Optional[str] = None + notes: Optional[str] = None + + +class Contact(ContactBase): + """Full contact schema""" + id: int + is_active: bool + vtiger_id: Optional[str] = None + created_at: datetime + updated_at: Optional[datetime] = None + companies: List[CompanyInfo] = [] # List of linked companies + + class Config: + from_attributes = True + + +# Hardware Schemas class HardwareBase(BaseModel): """Base hardware schema""" serial_number: str @@ -49,3 +135,69 @@ class Hardware(HardwareBase): class Config: from_attributes = True + + +# Vendor Schemas +class VendorBase(BaseModel): + """Base vendor schema""" + name: str + email: Optional[str] = None + phone: Optional[str] = None + + +class VendorCreate(BaseModel): + """Schema for creating a vendor""" + name: str + cvr_number: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + postal_code: Optional[str] = None + city: Optional[str] = None + website: Optional[str] = None + domain: Optional[str] = None + email_pattern: Optional[str] = None + category: str = 'general' + priority: int = 50 + notes: Optional[str] = None + is_active: bool = True + + +class VendorUpdate(BaseModel): + """Schema for updating a vendor""" + name: Optional[str] = None + cvr_number: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + postal_code: Optional[str] = None + city: Optional[str] = None + website: Optional[str] = None + domain: Optional[str] = None + email_pattern: Optional[str] = None + category: Optional[str] = None + priority: Optional[int] = None + notes: Optional[str] = None + is_active: Optional[bool] = None + + +class Vendor(VendorBase): + """Full vendor schema""" + id: int + cvr_number: Optional[str] = None + address: Optional[str] = None + postal_code: Optional[str] = None + city: Optional[str] = None + country: Optional[str] = None + website: Optional[str] = None + domain: Optional[str] = None + category: str + priority: int + notes: Optional[str] = None + is_active: bool + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + diff --git a/app/routers/__init__.py b/app/routers/__init__.py deleted file mode 100644 index bf6ba02..0000000 --- a/app/routers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Routers package""" diff --git a/app/routers/customers.py b/app/routers/customers.py deleted file mode 100644 index 28cc5f7..0000000 --- a/app/routers/customers.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Customers Router -API endpoints for customer management -""" - -from fastapi import APIRouter, HTTPException -from typing import List - -from app.models.schemas import Customer, CustomerCreate -from app.core.database import execute_query - -router = APIRouter() - - -@router.get("/customers", response_model=List[Customer]) -async def list_customers(): - """List all customers""" - query = "SELECT * FROM customers ORDER BY created_at DESC" - customers = execute_query(query) - return customers - - -@router.get("/customers/{customer_id}", response_model=Customer) -async def get_customer(customer_id: int): - """Get a specific customer""" - query = "SELECT * FROM customers WHERE id = %s" - customers = execute_query(query, (customer_id,)) - - if not customers: - raise HTTPException(status_code=404, detail="Customer not found") - - return customers[0] - - -@router.post("/customers", response_model=Customer) -async def create_customer(customer: CustomerCreate): - """Create a new customer""" - query = """ - INSERT INTO customers (name, email, phone, address) - VALUES (%s, %s, %s, %s) - RETURNING * - """ - result = execute_query( - query, - (customer.name, customer.email, customer.phone, customer.address) - ) - return result[0] diff --git a/app/services/cvr_service.py b/app/services/cvr_service.py new file mode 100644 index 0000000..20410ef --- /dev/null +++ b/app/services/cvr_service.py @@ -0,0 +1,136 @@ +""" +CVR.dk API service for looking up Danish company information +Free public API - no authentication required +Adapted from OmniSync for BMC Hub +""" +import asyncio +import aiohttp +import logging +from typing import Optional, Dict + +logger = logging.getLogger(__name__) + + +class CVRService: + """Service for CVR.dk API lookups""" + + BASE_URL = "https://cvrapi.dk/api" + + async def lookup_by_name(self, company_name: str) -> Optional[Dict]: + """ + Lookup company by name using CVR.dk API + + Args: + company_name: Company name to search for + + Returns: + Company data dict or None if not found + """ + if not company_name or len(company_name) < 3: + return None + + # Clean company name + clean_name = company_name.strip() + + try: + params = { + 'search': clean_name, + 'country': 'dk' + } + + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.BASE_URL}", + params=params, + timeout=aiohttp.ClientTimeout(total=10) + ) as response: + if response.status == 200: + data = await response.json() + + if data and 'vat' in data: + logger.info(f"✅ Found CVR {data['vat']} for '{company_name}'") + return { + 'cvr': data.get('vat'), + 'name': data.get('name'), + 'address': data.get('address'), + 'city': data.get('city'), + 'zipcode': data.get('zipcode'), + 'country': data.get('country'), + 'phone': data.get('phone'), + 'email': data.get('email'), + 'vat': data.get('vat'), + 'status': data.get('status') + } + + elif response.status == 404: + logger.warning(f"⚠️ No CVR found for '{company_name}'") + return None + + else: + logger.error(f"❌ CVR API error {response.status} for '{company_name}'") + return None + + except asyncio.TimeoutError: + logger.error(f"⏱️ CVR API timeout for '{company_name}'") + return None + + except Exception as e: + logger.error(f"❌ CVR lookup error for '{company_name}': {e}") + return None + + async def lookup_by_cvr(self, cvr_number: str) -> Optional[Dict]: + """ + Lookup company by CVR number + + Args: + cvr_number: CVR number (8 digits) + + Returns: + Company data dict or None if not found + """ + if not cvr_number: + return None + + # Extract only digits + cvr_clean = ''.join(filter(str.isdigit, str(cvr_number))) + + if len(cvr_clean) != 8: + logger.warning(f"⚠️ Invalid CVR number format: {cvr_number}") + return None + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.BASE_URL}", + params={'vat': cvr_clean, 'country': 'dk'}, + timeout=aiohttp.ClientTimeout(total=10) + ) as response: + if response.status == 200: + data = await response.json() + + if data and 'vat' in data: + logger.info(f"✅ Validated CVR {cvr_clean}") + return { + 'cvr': data.get('vat'), + 'name': data.get('name'), + 'address': data.get('address'), + 'city': data.get('city'), + 'zipcode': data.get('zipcode'), + 'postal_code': data.get('zipcode'), # Alias for consistency + 'country': data.get('country'), + 'phone': data.get('phone'), + 'email': data.get('email'), + 'vat': data.get('vat'), + 'status': data.get('status') + } + + return None + + except Exception as e: + logger.error(f"❌ CVR validation error for {cvr_number}: {e}") + return None + + +def get_cvr_service() -> CVRService: + """Get CVR service instance""" + return CVRService() diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html new file mode 100644 index 0000000..a5f8ca3 --- /dev/null +++ b/app/shared/frontend/base.html @@ -0,0 +1,603 @@ + + + + + + {% block title %}BMC Hub{% endblock %} + + + + {% block extra_css %}{% endblock %} + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + +{% block extra_js %}{% endblock %} + + \ No newline at end of file diff --git a/app/routers/system.py b/app/system/backend/router.py similarity index 100% rename from app/routers/system.py rename to app/system/backend/router.py diff --git a/docker-compose.yml b/docker-compose.yml index b7a1b4e..fd4cb10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,9 @@ services: # Mount for local development - live code reload - ./app:/app/app:ro - ./main.py:/app/main.py:ro + - ./scripts:/app/scripts:ro + # Mount OmniSync database for import (read-only) + - /Users/christianthomas/pakkemodtagelse/data:/omnisync_data:ro env_file: - .env environment: diff --git a/docs/design_reference/components.html b/docs/design_reference/components.html new file mode 100644 index 0000000..4b8192a --- /dev/null +++ b/docs/design_reference/components.html @@ -0,0 +1,562 @@ + + + + + + BMC Hub - Design System & Komponenter + + + + + + + + + +
+
+
+

Design System & Komponenter

+

En komplet oversigt over alle UI elementer i "Nordic Top" temaet.

+
+
+ + +
+
+
Farver & Typografi
+
+
+
+
Farvepalette
+
+
+
+
#0f4c75
+
Accent
+
+
+
#eef2f5
+
Accent Light
+
+
+
#2c3e50
+
Text Primary
+
+
+
#6c757d
+
Text Secondary
+
+
+
#198754
+
Success
+
+
+
#ffc107
+
Warning
+
+
+
#dc3545
+
Danger
+
+
+
#0dcaf0
+
Info
+
+
+
+
+
+
+
+
Typografi
+
+

H1 Overskrift (2.5rem)

+

H2 Overskrift (2rem)

+

H3 Overskrift (1.75rem)

+

H4 Overskrift (1.5rem)

+
H5 Overskrift (1.25rem)
+
H6 Overskrift (1rem)
+
+

Dette er en lead paragraph, der bruges til intro tekst.

+

Dette er almindelig brødtekst. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Dette er lille tekst.

+
+
+
+
+ + +
+
+
Knapper & Badges
+
+
+
+
Knapper
+
+
+ + + + + + + + + +
+
+ + + + +
+
+ + + +
+
+ + + +
+
+
+
+
+
+
Badges & Alerts
+
+
+ Primary + Secondary + Success + Danger + Warning + Info +
+
+ Pille Form + Aktiv + Kladde +
+ + +
+
+
+
+ + +
+
+
Formular Elementer
+
+
+
+
Input Felter
+
+
+ + +
Vi deler aldrig din email med andre.
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
Checkboxes, Radios & Switches
+
+
+
Checkboxes
+
+ + +
+
+ + +
+
+ +
+
Radio Buttons
+
+ + +
+
+ + +
+
+ +
+
Switches
+
+ + +
+
+ + +
+
+ +
+
Input Groups
+
+ @ + +
+
+ + DKK +
+
+
+
+
+
+ + +
+
+
Tabeller
+
+
+
+
+ Data Tabel + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KundeStatusOprettetBeløbHandling
+
+
+ BN +
+
+
BMC Networks
+
CVR: 12345678
+
+
+
Aktiv01. Dec 20234.500 DKK + + +
+
+
+ TC +
+
+
Tech Corp ApS
+
CVR: 87654321
+
+
+
Afventer28. Nov 202312.000 DKK + + +
+
+
+ LL +
+
+
Lokal Lageret
+
CVR: 11223344
+
+
+
Opsagt15. Okt 20232.100 DKK + + +
+
+
+ +
+
+
+ + +
+
+
Modals & Overlays
+
+
+
+
Modal Eksempel
+
+ +
+
+
+
+
+
Kort Varianter
+
+
+
+
Primary Card
+

Kort med accent farve baggrund.

+
+
+
+
+
Border Card
+

Kort med farvet kant.

+
+
+
+
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/design_reference/customers.html b/docs/design_reference/customers.html new file mode 100644 index 0000000..2a94977 --- /dev/null +++ b/docs/design_reference/customers.html @@ -0,0 +1,321 @@ + + + + + + BMC Hub - Nordic Topbar Customers + + + + + + + + +
+
+
+

Kunder

+

Administrer dine kunder

+
+
+ + +
+
+ +
+ + + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VirksomhedKontaktCVRStatusHardware
+
+
AG
+
+
Advokatgruppen A/S
+
København K
+
+
+
+
Jens Jensen
+
jens@advokat.dk
+
12345678Aktiv + Firewall + Switch + + +
+
+
BB
+
+
Byg & Bo ApS
+
Aarhus C
+
+
+
+
Mette Hansen
+
mh@bygbo.dk
+
87654321Aktiv + Router + + +
+
+
CM
+
+
Cafe Møller
+
Odense M
+
+
+
+
Peter Møller
+
pm@cafe.dk
+
11223344Afventer + - + + +
+
+ +
+ +
+
+
+ + + + diff --git a/docs/design_reference/form.html b/docs/design_reference/form.html new file mode 100644 index 0000000..f8a73c9 --- /dev/null +++ b/docs/design_reference/form.html @@ -0,0 +1,276 @@ + + + + + + BMC Hub - Opret Kunde + + + + + + + + +
+
+
+

Opret Ny Kunde

+

Udfyld oplysningerne herunder

+
+ Tilbage +
+ +
+
+
+
+
Virksomhedsoplysninger
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
Kontaktperson
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
Abonnement
+
+
+
+
+ + +
Inkluderer telefon og email support i åbningstiden.
+
+
+
+
+
+
+ + +
Døgnet rundt support og overvågning.
+
+
+
+
+ +
+ + +
+ +
+ +
+ + +
+
+
+
+
+
+ + + + diff --git a/docs/design_reference/index.html b/docs/design_reference/index.html new file mode 100644 index 0000000..79fff0b --- /dev/null +++ b/docs/design_reference/index.html @@ -0,0 +1,329 @@ + + + + + + BMC Hub - Nordic Topbar + + + + + + + + +
+
+
+

Dashboard

+

Velkommen tilbage, Christian

+
+
+ + +
+
+ +
+
+
+
+

Aktive Kunder

+ +
+

124

+ 12% denne måned +
+
+
+
+
+

Hardware

+ +
+

856

+ Enheder online +
+
+
+
+
+

Support

+ +
+

12

+ 3 kræver handling +
+
+
+
+
+

Omsætning

+ +
+

450k

+ Over budget +
+
+
+ +
+
+
+
Seneste Aktiviteter
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KundeHandlingStatusTid
Advokatgruppen A/SFirewall konfigurationFuldført10:23
Byg & Bo ApSLicens fornyelseAfventerI går
Cafe MøllerNetværksnedbrudKritiskI går
+
+
+
+
+
+
System Status
+ +
+
+ CPU LOAD + 24% +
+
+
+
+
+ +
+
+ MEMORY + 56% +
+
+
+
+
+ +
+
+ + Alle systemer kører optimalt. +
+
+
+
+
+
+ + + + diff --git a/docs/design_reference/login.html b/docs/design_reference/login.html new file mode 100644 index 0000000..bf5a6f6 --- /dev/null +++ b/docs/design_reference/login.html @@ -0,0 +1,119 @@ + + + + + + BMC Hub - Login + + + + + + + + + + diff --git a/docs/design_reference/settings.html b/docs/design_reference/settings.html new file mode 100644 index 0000000..ad93f21 --- /dev/null +++ b/docs/design_reference/settings.html @@ -0,0 +1,262 @@ + + + + + + BMC Hub - Indstillinger + + + + + + + + +
+
+ + +
+
+

Min Profil

+ +
+ +
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+

Præferencer

+
+ + +
+
+ + +
+
+
+
+
+ + + + diff --git a/docs/designplan.md b/docs/designplan.md new file mode 100644 index 0000000..635fb80 --- /dev/null +++ b/docs/designplan.md @@ -0,0 +1,139 @@ +# BMC Hub - Design System & UI Plan + +## 1. Design Filosofi: "Nordic Top" +Dette designdokument beskriver UI/UX-strategien for BMC Hub, baseret på templaten **10_nordic_top**. Designet er forankret i skandinavisk minimalisme med fokus på funktionalitet, luft og overskuelighed. + +**Nøgleprincipper:** +* **Fuld Bredde (Fluid Layout):** Udnytter hele skærmens bredde for maksimalt overblik, ideelt til store datatabeller og dashboards. +* **Top-Navigation:** En fastgjort topbar frigiver plads i siderne og giver en velkendt, hierarkisk navigationsstruktur med dropdowns. +* **Kort-baseret UI:** Indhold grupperes i "cards" med bløde skygger og rundede hjørner for at skabe visuelt hierarki. +* **Accent-farver:** En dyb blå (`#0f4c75`) bruges konsekvent til handlinger og branding, suppleret af lyse baggrunde. + +--- + +## 2. Visuel Identitet + +### Farvepalette +Designet benytter en stram palette defineret via CSS variabler for nem vedligeholdelse. + +| Variabel | Farvekode | Anvendelse | +| :--- | :--- | :--- | +| `--accent` | `#0f4c75` (Dyb Blå) | Primære knapper, aktiv navigation, branding, ikoner | +| `--accent-light` | `#eef2f5` (Lys Blå/Grå) | Hover-effekter, baggrund for ikoner, aktive menupunkter | +| `--text-primary` | `#2c3e50` (Mørkegrå) | Overskrifter, primær tekst | +| `--text-secondary` | `#6c757d` (Mellemgrå) | Brødtekst, labels, meta-data | +| `--bg-body` | `#f8f9fa` (Off-white) | Sidens baggrundsfarve | +| `--bg-card` | `#ffffff` (Hvid) | Kort, navbar, modaler | + +### Typografi +* **Font:** 'Inter', system-ui, sans-serif. +* **Vægtning:** + * **Bold (700):** Overskrifter, KPI-tal. + * **Medium (500):** Labels, menupunkter, knaptekst. + * **Regular (400):** Brødtekst. + +--- + +## 3. Komponentbibliotek + +### Navigation (Topbar) +* **Struktur:** Logo venstre, Menu center, Profil/Notifikationer højre. +* **Dropdowns:** Bruges til underkategorier (f.eks. Kunder -> Oversigt, Opret, Rapporter). +* **Aktiv Tilstand:** Markeres med `--accent-light` baggrund og `--accent` tekstfarve. + +### Knapper (Buttons) +* **Primær:** Fuld `--accent` baggrund. Bruges til hovedhandlinger (f.eks. "Opret Kunde", "Gem"). +* **Sekundær/Filter:** Hvid baggrund med border. Bliver farvet ved aktiv/hover. +* **Ikon-knapper:** Runde knapper til hurtige handlinger (f.eks. notifikationer, rediger). + +### Tabeller (Data Lists) +* **Design:** Clean look uden lodrette streger. +* **Features:** + * Avatar/Initialer for virksomheder. + * Status badges (pille-formede). + * Handlingsmenu (tre prikker) yderst til højre. + * Hover-effekt på rækker for læsbarhed. + +### Formularer (Inputs) +* **Styling:** Store, luftige inputfelter med blød border. +* **Fokus:** Tydelig `--accent` border og skygge ved fokus. +* **Gruppering:** Logisk opdeling med overskrifter (f.eks. "Virksomhedsoplysninger", "Kontaktperson"). +* **Kort-valg:** Radio buttons designet som klikbare kort (se Abonnement-valg i `form.html`). + +### Status Indikatorer (Badges) +Bruges til at vise tilstande hurtigt i tabeller. +* 🟢 **Success:** Aktiv, Fuldført (Grøn baggrund/tekst). +* 🟡 **Warning:** Afventer, Pause (Gul/Orange baggrund/tekst). +* 🔴 **Danger:** Kritisk, Opsagt (Rød baggrund/tekst). +* ⚪ **Neutral:** Hardware typer (Grå baggrund/tekst). + +--- + +## 4. Side-Skabeloner (Templates) + +Systemet består af 5 kerneskabeloner der dækker de fleste behov: + +### 1. Dashboard (`index.html`) +* **Formål:** Give hurtigt overblik over forretningens tilstand. +* **Indhold:** + * 4 KPI-kort i toppen (Kunder, Hardware, Support, Omsætning). + * Grafisk visning (placeholder) eller lister. + * "Seneste Aktiviteter" tabel. + * System Status panel (Server load, memory). + +### 2. Listevisning (`customers.html`) +* **Formål:** Administration af store datamængder. +* **Indhold:** + * Søgefelt og "Opret" knap i header. + * Filter-bar (Alle, Aktive, Inaktive, VIP). + * Datatabel med rig information. + * Paginering i bunden. + +### 3. Formular / Opret (`form.html`) +* **Formål:** Indtastning af data (CRUD operationer). +* **Indhold:** + * "Tilbage" knap for nem navigation. + * Opdelt formular i sektioner. + * Forskellige input typer: Text, Email, Tel, Select, Radio Cards, Textarea. + +### 4. Indstillinger (`settings.html`) +* **Formål:** Brugerprofil og systemkonfiguration. +* **Indhold:** + * 2-kolonne layout. + * Venstre: Vertikal navigationsmenu. + * Højre: Indholdspaneler (Profilbillede, Stamdata, Toggles for notifikationer/Dark mode). + +### 5. Login (`login.html`) +* **Formål:** Adgangskontrol. +* **Indhold:** + * Centreret kort på fuld skærm. + * Logo og velkomsttekst. + * Email/Password felter. + * "Husk mig" og "Glemt kode" funktioner. + +--- + +## 5. Brugerrejse & Navigation + +### Flow Eksempel: Opret ny kunde +1. **Login:** Bruger logger ind via `login.html`. +2. **Dashboard:** Lander på `index.html` og ser overblik. +3. **Navigation:** Klikker på "Kunder" i topmenuen -> Vælger "Oversigt" (eller direkte "Opret ny kunde"). +4. **Liste:** Ser listen på `customers.html`, søger evt. efter eksisterende. +5. **Handling:** Klikker "Opret Kunde" knappen. +6. **Formular:** Udfylder data på `form.html`. + * Vælger branche via dropdown. + * Vælger abonnementspakke via visuelle kort. +7. **Afslutning:** Klikker "Opret Kunde" -> Sendes tilbage til `customers.html` (eller detaljevisning). + +--- + +## 6. Teknisk Implementation + +* **Framework:** Bootstrap 5.3.2 (CSS/JS). +* **Ikoner:** Bootstrap Icons (CDN). +* **Layout:** `container-fluid` for fuld bredde, `row` og `col-*` grid system. +* **Responsivitet:** + * Navbar kollapser til "burger menu" på mobil. + * Tabeller bliver scrollbare horisontalt (`table-responsive`). + * Grid system tilpasser sig fra 4 kolonner (desktop) til 1 kolonne (mobil). diff --git a/main.py b/main.py index ac6e776..f63ce7f 100644 --- a/main.py +++ b/main.py @@ -12,12 +12,18 @@ from contextlib import asynccontextmanager from app.core.config import settings from app.core.database import init_db -from app.routers import ( - customers, - hardware, - billing, - system, -) + +# Import Feature Routers +from app.auth.backend import router as auth_api +from app.auth.backend import views as auth_views +from app.customers.backend import router as customers_api +from app.customers.backend import views as customers_views +from app.contacts.backend import router as contacts_api +from app.contacts.backend import views as contacts_views +from app.hardware.backend import router as hardware_api +from app.billing.backend import router as billing_api +from app.system.backend import router as system_api +from app.dashboard.backend import views as dashboard_views # Configure logging logging.basicConfig( @@ -74,19 +80,22 @@ app.add_middleware( ) # Include routers -app.include_router(customers.router, prefix="/api/v1", tags=["Customers"]) -app.include_router(hardware.router, prefix="/api/v1", tags=["Hardware"]) -app.include_router(billing.router, prefix="/api/v1", tags=["Billing"]) -app.include_router(system.router, prefix="/api/v1", tags=["System"]) +app.include_router(auth_api.router, prefix="/api/v1/auth", tags=["Authentication"]) +app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"]) +app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"]) +app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"]) +app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"]) +app.include_router(system_api.router, prefix="/api/v1", tags=["System"]) + +# Frontend Routers +app.include_router(auth_views.router, tags=["Frontend"]) +app.include_router(dashboard_views.router, tags=["Frontend"]) +app.include_router(customers_views.router, tags=["Frontend"]) +app.include_router(contacts_views.router, tags=["Frontend"]) # Serve static files (UI) app.mount("/static", StaticFiles(directory="static", html=True), name="static") -@app.get("/") -async def root(): - """Redirect to dashboard""" - return RedirectResponse(url="/static/index.html") - @app.get("/health") async def health_check(): """Health check endpoint""" diff --git a/migrations/002_auth_system.sql b/migrations/002_auth_system.sql new file mode 100644 index 0000000..2682ea2 --- /dev/null +++ b/migrations/002_auth_system.sql @@ -0,0 +1,215 @@ +-- Migration 002: Authentication & Authorization System +-- Based on OmniSync auth implementation + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + user_id SERIAL PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + is_superadmin BOOLEAN DEFAULT FALSE, + failed_login_attempts INTEGER DEFAULT 0, + locked_until TIMESTAMP, + last_login_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Groups table +CREATE TABLE IF NOT EXISTS groups ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Permissions table +CREATE TABLE IF NOT EXISTS permissions ( + id SERIAL PRIMARY KEY, + code VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + category VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- User-Group relationship +CREATE TABLE IF NOT EXISTS user_groups ( + user_id INTEGER REFERENCES users(user_id) ON DELETE CASCADE, + group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, + assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, group_id) +); + +-- Group-Permission relationship +CREATE TABLE IF NOT EXISTS group_permissions ( + group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, + permission_id INTEGER REFERENCES permissions(id) ON DELETE CASCADE, + PRIMARY KEY (group_id, permission_id) +); + +-- Sessions table (for JWT token revocation) +CREATE TABLE IF NOT EXISTS sessions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(user_id) ON DELETE CASCADE, + token_jti VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMP NOT NULL, + revoked BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Audit log table +CREATE TABLE IF NOT EXISTS audit_log ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL, + username VARCHAR(100), + action VARCHAR(100) NOT NULL, + resource_type VARCHAR(100), + resource_id INTEGER, + resource_name VARCHAR(255), + before_value TEXT, + after_value TEXT, + success BOOLEAN DEFAULT TRUE, + error_message TEXT, + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_sessions_jti ON sessions(token_jti); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); +CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at); + +-- Insert default permissions +INSERT INTO permissions (code, description, category) VALUES +-- Customers +('customers.view', 'View customers', 'customers'), +('customers.edit', 'Edit customers', 'customers'), +('customers.create', 'Create customers', 'customers'), +('customers.delete', 'Delete customers', 'customers'), + +-- Hardware +('hardware.view', 'View hardware', 'hardware'), +('hardware.edit', 'Edit hardware', 'hardware'), +('hardware.create', 'Create hardware', 'hardware'), +('hardware.delete', 'Delete hardware', 'hardware'), +('hardware.assign', 'Assign hardware to customers', 'hardware'), + +-- Billing +('billing.view', 'View billing information', 'billing'), +('billing.edit', 'Edit billing information', 'billing'), +('billing.approve', 'Approve billing items', 'billing'), + +-- System +('system.view', 'View system settings', 'system'), +('system.edit', 'Edit system settings', 'system'), + +-- Reports +('reports.view', 'View reports', 'reports'), +('reports.export', 'Export reports', 'reports'), + +-- Audit +('audit.view', 'View audit logs', 'audit'), + +-- Admin +('users.manage', 'Manage users and groups', 'admin'), +('permissions.manage', 'Manage permissions', 'admin'), +('system.admin', 'Full system administration', 'admin') +ON CONFLICT (code) DO NOTHING; + +-- Insert default groups +INSERT INTO groups (name, description) VALUES +('Administrators', 'Full system access'), +('Managers', 'Can manage customers and billing'), +('Technicians', 'Can manage hardware and assignments'), +('Viewers', 'Read-only access') +ON CONFLICT (name) DO NOTHING; + +-- Assign permissions to Administrators group (all permissions) +INSERT INTO group_permissions (group_id, permission_id) +SELECT g.id, p.id +FROM groups g +CROSS JOIN permissions p +WHERE g.name = 'Administrators' +ON CONFLICT DO NOTHING; + +-- Assign permissions to Managers group +INSERT INTO group_permissions (group_id, permission_id) +SELECT g.id, p.id +FROM groups g +CROSS JOIN permissions p +WHERE g.name = 'Managers' AND p.code IN ( + 'customers.view', 'customers.edit', 'customers.create', + 'hardware.view', + 'billing.view', 'billing.edit', 'billing.approve', + 'reports.view', 'reports.export' +) +ON CONFLICT DO NOTHING; + +-- Assign permissions to Technicians group +INSERT INTO group_permissions (group_id, permission_id) +SELECT g.id, p.id +FROM groups g +CROSS JOIN permissions p +WHERE g.name = 'Technicians' AND p.code IN ( + 'customers.view', + 'hardware.view', 'hardware.edit', 'hardware.create', 'hardware.assign', + 'reports.view' +) +ON CONFLICT DO NOTHING; + +-- Assign permissions to Viewers group +INSERT INTO group_permissions (group_id, permission_id) +SELECT g.id, p.id +FROM groups g +CROSS JOIN permissions p +WHERE g.name = 'Viewers' AND p.code IN ( + 'customers.view', + 'hardware.view', + 'billing.view', + 'reports.view' +) +ON CONFLICT DO NOTHING; + +-- Create default admin user (password: admin123, hashed with SHA256) +-- SHA256 hash of 'admin123' +INSERT INTO users (username, email, password_hash, full_name, is_superadmin, is_active) +VALUES ( + 'admin', + 'admin@bmcnetworks.dk', + '240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9', + 'System Administrator', + TRUE, + TRUE +) +ON CONFLICT (username) DO NOTHING; + +-- Assign admin user to Administrators group +INSERT INTO user_groups (user_id, group_id) +SELECT u.user_id, g.id +FROM users u +CROSS JOIN groups g +WHERE u.username = 'admin' AND g.name = 'Administrators' +ON CONFLICT DO NOTHING; + +-- Update timestamp trigger function +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Add trigger to users table +DROP TRIGGER IF EXISTS update_users_updated_at ON users; +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/003_extend_customers.sql b/migrations/003_extend_customers.sql new file mode 100644 index 0000000..0f1a26b --- /dev/null +++ b/migrations/003_extend_customers.sql @@ -0,0 +1,71 @@ +-- Migration 003: Udvid customers tabel med felter fra OmniSync +-- Dato: 6. december 2025 + +-- Tilføj nye kolonner til customers tabel +ALTER TABLE customers +ADD COLUMN IF NOT EXISTS cvr_number VARCHAR(20) UNIQUE, +ADD COLUMN IF NOT EXISTS email_domain VARCHAR(255), +ADD COLUMN IF NOT EXISTS city VARCHAR(100), +ADD COLUMN IF NOT EXISTS postal_code VARCHAR(10), +ADD COLUMN IF NOT EXISTS country VARCHAR(2) DEFAULT 'DK', +ADD COLUMN IF NOT EXISTS website VARCHAR(255), +ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE, + +-- vTiger integration +ADD COLUMN IF NOT EXISTS vtiger_id VARCHAR(50) UNIQUE, +ADD COLUMN IF NOT EXISTS vtiger_account_no VARCHAR(50), +ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMP, + +-- E-conomic integration +ADD COLUMN IF NOT EXISTS economic_customer_number INTEGER, +ADD COLUMN IF NOT EXISTS payment_terms_number INTEGER, +ADD COLUMN IF NOT EXISTS payment_terms_type VARCHAR(50), +ADD COLUMN IF NOT EXISTS vat_zone_number INTEGER, +ADD COLUMN IF NOT EXISTS customer_group_number INTEGER, +ADD COLUMN IF NOT EXISTS barred BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS currency_code VARCHAR(3) DEFAULT 'DKK', +ADD COLUMN IF NOT EXISTS ean VARCHAR(13), +ADD COLUMN IF NOT EXISTS public_entry_number VARCHAR(50), + +-- Additional contact info +ADD COLUMN IF NOT EXISTS invoice_email VARCHAR(255), +ADD COLUMN IF NOT EXISTS mobile_phone VARCHAR(50), + +-- Customer relationships +ADD COLUMN IF NOT EXISTS parent_customer_id INTEGER REFERENCES customers(id), +ADD COLUMN IF NOT EXISTS billing_customer_id INTEGER REFERENCES customers(id); + +-- Opret nye indexes +CREATE INDEX IF NOT EXISTS idx_customers_cvr ON customers(cvr_number); +CREATE INDEX IF NOT EXISTS idx_customers_vtiger ON customers(vtiger_id); +CREATE INDEX IF NOT EXISTS idx_customers_economic ON customers(economic_customer_number); +CREATE INDEX IF NOT EXISTS idx_customers_active ON customers(is_active); +CREATE INDEX IF NOT EXISTS idx_customers_parent ON customers(parent_customer_id); +CREATE INDEX IF NOT EXISTS idx_customers_billing ON customers(billing_customer_id); +CREATE INDEX IF NOT EXISTS idx_customers_city ON customers(city); +CREATE INDEX IF NOT EXISTS idx_customers_name ON customers(name); + +-- Opdater trigger function hvis den ikke findes +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Tilføj trigger til customers (hvis ikke allerede eksisterer) +DROP TRIGGER IF EXISTS update_customers_updated_at ON customers; +CREATE TRIGGER update_customers_updated_at + BEFORE UPDATE ON customers + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Tilføj kommentarer til dokumentation +COMMENT ON COLUMN customers.cvr_number IS 'Danish CVR (company registration) number'; +COMMENT ON COLUMN customers.email_domain IS 'Primary email domain for customer'; +COMMENT ON COLUMN customers.vtiger_id IS 'Reference to vTiger CRM account ID'; +COMMENT ON COLUMN customers.economic_customer_number IS 'Reference to e-conomic customer number'; +COMMENT ON COLUMN customers.parent_customer_id IS 'Parent company if part of a group'; +COMMENT ON COLUMN customers.billing_customer_id IS 'Billing account if different from this customer'; +COMMENT ON COLUMN customers.barred IS 'Whether customer is barred from ordering'; diff --git a/migrations/004_contacts_relationships.sql b/migrations/004_contacts_relationships.sql new file mode 100644 index 0000000..2eb23b7 --- /dev/null +++ b/migrations/004_contacts_relationships.sql @@ -0,0 +1,78 @@ +-- Migration 004: Opret contacts og customer relationships tabeller +-- Dato: 6. december 2025 + +-- Contacts tabel +CREATE TABLE IF NOT EXISTS contacts ( + id SERIAL PRIMARY KEY, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(255), + phone VARCHAR(50), + mobile VARCHAR(50), + title VARCHAR(100), + department VARCHAR(100), + is_active BOOLEAN DEFAULT TRUE, + vtiger_id VARCHAR(50) UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Junction tabel for mange-til-mange relation mellem contacts og customers +CREATE TABLE IF NOT EXISTS contact_companies ( + id SERIAL PRIMARY KEY, + contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + is_primary BOOLEAN DEFAULT FALSE, + role VARCHAR(100), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(contact_id, customer_id) +); + +-- Customer relationships tabel (for parent/child og billing strukturer) +CREATE TABLE IF NOT EXISTS customer_relationships ( + id SERIAL PRIMARY KEY, + parent_customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + child_customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + relationship_type VARCHAR(50) NOT NULL CHECK (relationship_type IN ('parent_child', 'billing', 'partnership', 'subsidiary')), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(parent_customer_id, child_customer_id, relationship_type) +); + +-- Indexes for contacts +CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email); +CREATE INDEX IF NOT EXISTS idx_contacts_active ON contacts(is_active); +CREATE INDEX IF NOT EXISTS idx_contacts_vtiger ON contacts(vtiger_id); +CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(first_name, last_name); + +-- Indexes for contact_companies +CREATE INDEX IF NOT EXISTS idx_contact_companies_contact ON contact_companies(contact_id); +CREATE INDEX IF NOT EXISTS idx_contact_companies_customer ON contact_companies(customer_id); +CREATE INDEX IF NOT EXISTS idx_contact_companies_primary ON contact_companies(customer_id, is_primary); + +-- Indexes for customer_relationships +CREATE INDEX IF NOT EXISTS idx_customer_relationships_parent ON customer_relationships(parent_customer_id); +CREATE INDEX IF NOT EXISTS idx_customer_relationships_child ON customer_relationships(child_customer_id); +CREATE INDEX IF NOT EXISTS idx_customer_relationships_type ON customer_relationships(relationship_type); + +-- Triggers for updated_at +DROP TRIGGER IF EXISTS update_contacts_updated_at ON contacts; +CREATE TRIGGER update_contacts_updated_at + BEFORE UPDATE ON contacts + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_customer_relationships_updated_at ON customer_relationships; +CREATE TRIGGER update_customer_relationships_updated_at + BEFORE UPDATE ON customer_relationships + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Kommentarer +COMMENT ON TABLE contacts IS 'Contact persons associated with one or more customers'; +COMMENT ON TABLE contact_companies IS 'Many-to-many junction table linking contacts to customers'; +COMMENT ON TABLE customer_relationships IS 'Hierarchical and billing relationships between customers'; +COMMENT ON COLUMN contact_companies.is_primary IS 'Whether this is the primary contact for this customer'; +COMMENT ON COLUMN customer_relationships.relationship_type IS 'Type of relationship: parent_child, billing, partnership, subsidiary'; diff --git a/migrations/005_vendors.sql b/migrations/005_vendors.sql new file mode 100644 index 0000000..6893eb8 --- /dev/null +++ b/migrations/005_vendors.sql @@ -0,0 +1,60 @@ +-- Migration: Add vendors table +-- Similar to customers but for suppliers/vendors + +CREATE TABLE IF NOT EXISTS vendors ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + cvr_number VARCHAR(20) UNIQUE, + + -- Contact information + email VARCHAR(255), + phone VARCHAR(50), + website VARCHAR(255), + + -- Address + address TEXT, + postal_code VARCHAR(10), + city VARCHAR(100), + country VARCHAR(100) DEFAULT 'Danmark', + + -- Integration IDs + economic_supplier_number INTEGER, + + -- Vendor specific + domain VARCHAR(255), + email_pattern TEXT, + category VARCHAR(50) DEFAULT 'general', + priority INTEGER DEFAULT 50, + + -- Metadata + notes TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by_user_id INTEGER, + updated_by_user_id INTEGER +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_vendors_name ON vendors(name); +CREATE INDEX IF NOT EXISTS idx_vendors_cvr ON vendors(cvr_number); +CREATE INDEX IF NOT EXISTS idx_vendors_domain ON vendors(domain); +CREATE INDEX IF NOT EXISTS idx_vendors_active ON vendors(is_active); + +-- Updated timestamp trigger +CREATE OR REPLACE FUNCTION update_vendors_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER vendors_updated_at_trigger + BEFORE UPDATE ON vendors + FOR EACH ROW + EXECUTE FUNCTION update_vendors_updated_at(); + +COMMENT ON TABLE vendors IS 'Suppliers and vendors'; +COMMENT ON COLUMN vendors.category IS 'Vendor category: hardware, software, telecom, services, etc.'; +COMMENT ON COLUMN vendors.priority IS 'Priority level 1-100, higher = more important'; diff --git a/requirements.txt b/requirements.txt index 1113e62..97a4ca1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,6 @@ pydantic==2.10.3 pydantic-settings==2.6.1 python-dotenv==1.0.1 python-multipart==0.0.17 +jinja2==3.1.4 +pyjwt==2.9.0 +aiohttp==3.10.10 diff --git a/scripts/import_from_omnisync.py b/scripts/import_from_omnisync.py new file mode 100644 index 0000000..08f0395 --- /dev/null +++ b/scripts/import_from_omnisync.py @@ -0,0 +1,245 @@ +""" +Import customers and contacts from OmniSync database with better error handling +""" + +import psycopg2 +from psycopg2.extras import RealDictCursor +import sqlite3 +import os +import logging + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +OMNISYNC_DB = '/omnisync_data/fakturering.db' + + +def get_postgres_connection(): + """Get PostgreSQL connection""" + database_url = os.getenv('DATABASE_URL', 'postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub') + return psycopg2.connect(database_url, cursor_factory=RealDictCursor) + + +def get_sqlite_connection(): + """Get SQLite connection to OmniSync database""" + return sqlite3.connect(OMNISYNC_DB) + + +def import_customers(): + """Import customers from OmniSync""" + sqlite_conn = get_sqlite_connection() + sqlite_cursor = sqlite_conn.cursor() + + imported = 0 + skipped = 0 + errors = [] + + try: + # Get active customers from OmniSync + sqlite_cursor.execute(""" + SELECT + name, cvr_number, email, phone, address, city, postal_code, + country, website, vtiger_id, economic_customer_number, + email_domain, created_at + FROM customers + WHERE active = 1 AND deleted_at IS NULL + ORDER BY name + """) + + customers = sqlite_cursor.fetchall() + logger.info(f"📥 Found {len(customers)} active customers in OmniSync") + + for row in customers: + name, cvr, email, phone, address, city, postal_code, country, website, vtiger_id, economic_no, email_domain, created_at = row + + # Skip if no name + if not name or name.strip() == '': + skipped += 1 + continue + + # Insert each customer individually + individual_conn = get_postgres_connection() + individual_cursor = individual_conn.cursor() + + try: + individual_cursor.execute(""" + INSERT INTO customers ( + name, cvr_number, email, phone, address, postal_code, city, + country, website, vtiger_id, economic_customer_number, + email_domain, is_active, created_at + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (cvr_number) DO UPDATE SET + name = EXCLUDED.name, + email = EXCLUDED.email, + phone = EXCLUDED.phone, + address = EXCLUDED.address, + postal_code = EXCLUDED.postal_code, + city = EXCLUDED.city, + website = EXCLUDED.website, + vtiger_id = EXCLUDED.vtiger_id, + economic_customer_number = EXCLUDED.economic_customer_number + RETURNING id + """, ( + name, cvr, email, phone, address, postal_code, city, + country or 'Danmark', website, vtiger_id, economic_no, + email_domain, True, created_at + )) + + result = individual_cursor.fetchone() + individual_conn.commit() + + if result: + imported += 1 + if imported % 50 == 0: + logger.info(f" Imported {imported} customers...") + + except Exception as e: + error_msg = str(e)[:100] + if imported + skipped < 10: # Only log first 10 errors in detail + logger.warning(f" ⚠️ Could not import '{name}': {error_msg}") + errors.append((name, error_msg)) + skipped += 1 + finally: + individual_cursor.close() + individual_conn.close() + + logger.info(f"✅ Customers: {imported} imported, {skipped} skipped") + if len(errors) > 10: + logger.info(f" (Suppressed {len(errors)-10} error messages)") + + return imported + + except Exception as e: + logger.error(f"❌ Customer import failed: {e}") + raise + finally: + sqlite_cursor.close() + sqlite_conn.close() + + +def import_contacts(): + """Import contacts from OmniSync""" + sqlite_conn = get_sqlite_connection() + sqlite_cursor = sqlite_conn.cursor() + + imported = 0 + skipped = 0 + errors = [] + + try: + # Get contacts + sqlite_cursor.execute(""" + SELECT + c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, + c.title, c.department, c.active, c.vtiger_id, c.created_at + FROM contacts c + WHERE c.active = 1 AND c.deleted_at IS NULL + ORDER BY c.last_name, c.first_name + """) + + contacts = sqlite_cursor.fetchall() + logger.info(f"📥 Found {len(contacts)} active contacts in OmniSync") + + for row in contacts: + omnisync_id, first_name, last_name, email, phone, mobile, title, department, active, vtiger_id, created_at = row + + # Skip if no name + if not first_name and not last_name: + skipped += 1 + continue + + # Individual connection per contact + postgres_conn = get_postgres_connection() + postgres_cursor = postgres_conn.cursor() + + try: + # Insert contact + postgres_cursor.execute(""" + INSERT INTO contacts ( + first_name, last_name, email, phone, mobile, + title, department, is_active, vtiger_id, created_at + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + first_name or '', last_name or '', email, phone, mobile, + title, department, bool(active), vtiger_id, created_at + )) + + result = postgres_cursor.fetchone() + contact_id = result['id'] + + # Get company relationships from OmniSync + sqlite_cursor.execute(""" + SELECT customer_id, is_primary, role, notes + FROM contact_companies + WHERE contact_id = ? + """, (omnisync_id,)) + + companies = sqlite_cursor.fetchall() + + # Link to companies in BMC Hub + for company_row in companies: + customer_id_omnisync, is_primary, role, notes = company_row + + # Find matching customer in BMC Hub by vtiger_id or name + # First get the OmniSync customer + sqlite_cursor.execute("SELECT vtiger_id, name FROM customers WHERE id = ?", (customer_id_omnisync,)) + customer_data = sqlite_cursor.fetchone() + + if customer_data: + vtiger_id_customer, customer_name = customer_data + + # Find in BMC Hub + if vtiger_id_customer: + postgres_cursor.execute("SELECT id FROM customers WHERE vtiger_id = %s", (vtiger_id_customer,)) + else: + postgres_cursor.execute("SELECT id FROM customers WHERE name = %s LIMIT 1", (customer_name,)) + + bmc_customer = postgres_cursor.fetchone() + + if bmc_customer: + postgres_cursor.execute(""" + INSERT INTO contact_companies (contact_id, customer_id, is_primary, role, notes) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT DO NOTHING + """, (contact_id, bmc_customer['id'], bool(is_primary), role, notes)) + + postgres_conn.commit() + imported += 1 + if imported % 50 == 0: + logger.info(f" Imported {imported} contacts...") + + except Exception as e: + error_msg = str(e)[:100] + if imported + skipped < 10: + logger.warning(f" ⚠️ Could not import '{first_name} {last_name}': {error_msg}") + errors.append((f"{first_name} {last_name}", error_msg)) + skipped += 1 + finally: + postgres_cursor.close() + postgres_conn.close() + + logger.info(f"✅ Contacts: {imported} imported, {skipped} skipped") + if len(errors) > 10: + logger.info(f" (Suppressed {len(errors)-10} error messages)") + + return imported + + except Exception as e: + logger.error(f"❌ Contact import failed: {e}") + raise + finally: + sqlite_cursor.close() + sqlite_conn.close() + + +if __name__ == "__main__": + logger.info("🚀 Starting OmniSync import...") + logger.info(f"📂 Source: {OMNISYNC_DB}") + + customer_count = import_customers() + contact_count = import_contacts() + + logger.info(f"\n🎉 Import completed!") + logger.info(f" Customers: {customer_count}") + logger.info(f" Contacts: {contact_count}") diff --git a/scripts/import_sample_data.py b/scripts/import_sample_data.py new file mode 100644 index 0000000..7514a0c --- /dev/null +++ b/scripts/import_sample_data.py @@ -0,0 +1,232 @@ +""" +Data import script for BMC Hub +Imports customers and contacts from CSV files or creates sample data +""" + +import psycopg2 +from psycopg2.extras import RealDictCursor +import os +import logging + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +def get_connection(): + """Get database connection""" + database_url = os.getenv('DATABASE_URL', 'postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub') + return psycopg2.connect(database_url, cursor_factory=RealDictCursor) + + +def import_sample_data(): + """Import sample customers and contacts for testing""" + conn = get_connection() + cursor = conn.cursor() + + try: + # Sample customers + customers = [ + { + 'name': 'TDC A/S', + 'cvr_number': '14773908', + 'address': 'Teglholmsgade 1', + 'postal_code': '2450', + 'city': 'København SV', + 'email': 'info@tdc.dk', + 'phone': '+45 70 70 40 30', + 'website': 'https://tdc.dk', + 'is_active': True + }, + { + 'name': 'Dansk Supermarked Group', + 'cvr_number': '16314439', + 'address': 'Roskildevej 65', + 'postal_code': '2620', + 'city': 'Albertslund', + 'email': 'info@dsg.dk', + 'phone': '+45 43 86 43 86', + 'website': 'https://dansksupermarked.dk', + 'is_active': True + }, + { + 'name': 'Nets Denmark A/S', + 'cvr_number': '20016175', + 'address': 'Lautrupbjerg 10', + 'postal_code': '2750', + 'city': 'Ballerup', + 'email': 'info@nets.eu', + 'phone': '+45 44 68 44 68', + 'website': 'https://nets.eu', + 'is_active': True + }, + { + 'name': 'Salling Group', + 'cvr_number': '30521736', + 'address': 'Skanderborgvej 121', + 'postal_code': '8260', + 'city': 'Viby J', + 'email': 'info@sallinggroup.com', + 'phone': '+45 87 93 35 00', + 'website': 'https://sallinggroup.com', + 'is_active': True + }, + { + 'name': 'ISS A/S', + 'cvr_number': '28861341', + 'address': 'Buddingevej 197', + 'postal_code': '2860', + 'city': 'Søborg', + 'email': 'info@dk.issworld.com', + 'phone': '+45 38 17 00 00', + 'website': 'https://issworld.com', + 'is_active': True + } + ] + + logger.info("Importing customers...") + customer_ids = {} + + for customer in customers: + cursor.execute(""" + INSERT INTO customers ( + name, cvr_number, address, postal_code, city, + email, phone, website, is_active + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + customer['name'], customer['cvr_number'], customer['address'], + customer['postal_code'], customer['city'], customer['email'], + customer['phone'], customer['website'], customer['is_active'] + )) + result = cursor.fetchone() + customer_id = result['id'] + customer_ids[customer['name']] = customer_id + logger.info(f"✅ Imported customer: {customer['name']} (ID: {customer_id})") + + # Sample contacts + contacts = [ + { + 'first_name': 'Lars', + 'last_name': 'Jensen', + 'email': 'lars.jensen@tdc.dk', + 'phone': '+45 70 70 40 31', + 'mobile': '+45 20 12 34 56', + 'title': 'CTO', + 'department': 'IT', + 'companies': ['TDC A/S'], + 'is_primary': True, + 'role': 'Teknisk kontakt' + }, + { + 'first_name': 'Mette', + 'last_name': 'Nielsen', + 'email': 'mette.nielsen@dsg.dk', + 'phone': '+45 43 86 43 87', + 'mobile': '+45 30 98 76 54', + 'title': 'IT Manager', + 'department': 'IT Operations', + 'companies': ['Dansk Supermarked Group'], + 'is_primary': True, + 'role': 'Primær kontakt' + }, + { + 'first_name': 'Peter', + 'last_name': 'Hansen', + 'email': 'peter.hansen@nets.eu', + 'phone': '+45 44 68 44 69', + 'mobile': '+45 40 11 22 33', + 'title': 'Network Engineer', + 'department': 'Infrastructure', + 'companies': ['Nets Denmark A/S'], + 'is_primary': True, + 'role': 'Teknisk ansvarlig' + }, + { + 'first_name': 'Anne', + 'last_name': 'Andersen', + 'email': 'anne.andersen@sallinggroup.com', + 'phone': '+45 87 93 35 01', + 'mobile': '+45 50 44 55 66', + 'title': 'IT Director', + 'department': 'IT', + 'companies': ['Salling Group'], + 'is_primary': True, + 'role': 'Beslutningsansvarlig' + }, + { + 'first_name': 'Thomas', + 'last_name': 'Christensen', + 'email': 'thomas.christensen@issworld.com', + 'phone': '+45 38 17 00 01', + 'mobile': '+45 60 77 88 99', + 'title': 'Senior IT Consultant', + 'department': 'IT Services', + 'companies': ['ISS A/S', 'Nets Denmark A/S'], # Multi-company contact + 'is_primary': False, + 'role': 'Konsulent' + } + ] + + logger.info("Importing contacts...") + + for contact in contacts: + # Insert contact + cursor.execute(""" + INSERT INTO contacts ( + first_name, last_name, email, phone, mobile, + title, department, is_active + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + contact['first_name'], contact['last_name'], contact['email'], + contact['phone'], contact['mobile'], contact['title'], + contact['department'], True + )) + result = cursor.fetchone() + contact_id = result['id'] + + # Link to companies + for company_name in contact['companies']: + if company_name in customer_ids: + cursor.execute(""" + INSERT INTO contact_companies ( + contact_id, customer_id, is_primary, role + ) VALUES (%s, %s, %s, %s) + """, ( + contact_id, customer_ids[company_name], + contact['is_primary'] if contact['companies'][0] == company_name else False, + contact['role'] + )) + + logger.info(f"✅ Imported contact: {contact['first_name']} {contact['last_name']} (ID: {contact_id}, Companies: {len(contact['companies'])})") + + conn.commit() + logger.info("🎉 Sample data import completed successfully!") + + # Print summary + cursor.execute("SELECT COUNT(*) as count FROM customers") + customer_count = cursor.fetchone()['count'] + + cursor.execute("SELECT COUNT(*) as count FROM contacts") + contact_count = cursor.fetchone()['count'] + + cursor.execute("SELECT COUNT(*) as count FROM contact_companies") + link_count = cursor.fetchone()['count'] + + logger.info(f"\n📊 Summary:") + logger.info(f" Customers: {customer_count}") + logger.info(f" Contacts: {contact_count}") + logger.info(f" Company-Contact Links: {link_count}") + + except Exception as e: + conn.rollback() + logger.error(f"❌ Import failed: {e}") + raise + finally: + cursor.close() + conn.close() + + +if __name__ == "__main__": + logger.info("🚀 Starting data import...") + import_sample_data() diff --git a/static/design_templates/01_nordic/customers.html b/static/design_templates/01_nordic/customers.html new file mode 100644 index 0000000..2cbf561 --- /dev/null +++ b/static/design_templates/01_nordic/customers.html @@ -0,0 +1,282 @@ + + + + + + BMC Hub - Nordic Customers + + + + + + + + +
+
+
+

Kunder

+

Administrer dine kunder og deres services

+
+
+ + +
+
+ +
+
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VirksomhedKontaktpersonCVR NummerStatusHardware
+
+
A
+
+
Advokatgruppen A/S
+
København K
+
+
+
+
Jens Jensen
+
jens@advokat.dk
+
12345678Aktiv +
+ Firewall + Switch +
+
+ +
+
+
B
+
+
Byg & Bo ApS
+
Aarhus C
+
+
+
+
Mette Hansen
+
mh@bygbo.dk
+
87654321Aktiv +
+ Router +
+
+ +
+
+
C
+
+
Cafe Møller
+
Odense M
+
+
+
+
Peter Møller
+
pm@cafe.dk
+
11223344Afventer + - + + +
+
+
D
+
+
Dansk Design Hus
+
København Ø
+
+
+
+
Lars Larsen
+
ll@design.dk
+
44332211Aktiv +
+ Firewall + AP x4 +
+
+ +
+
+ +
+
Viser 1-4 af 124 kunder
+ +
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/01_nordic/index.html b/static/design_templates/01_nordic/index.html new file mode 100644 index 0000000..21c6234 --- /dev/null +++ b/static/design_templates/01_nordic/index.html @@ -0,0 +1,292 @@ + + + + + + BMC Hub - Nordic Dashboard + + + + + + + + +
+
+
+

Oversigt

+

Velkommen tilbage, Christian

+
+
+ + +
+
+ +
+
+
+
+
+

Aktive Kunder

+

124

+
+ +12% +
+
+
+
+
+
+
+

Hardware Enheder

+

856

+
+ Online +
+
+
+
+
+
+
+

Support Sager

+

12

+
+ 3 Haster +
+
+
+
+
+
+
+

Omsætning (Mdr)

+

450k

+
+ +5% +
+
+
+
+ +
+
+
+
+
Seneste Aktiviteter
+ Se alle +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KundeHandlingStatusDato
+
+
A
+ Advokatgruppen A/S +
+
Ny firewall konfigureretFuldførtI dag, 10:23
+
+
B
+ Byg & Bo ApS +
+
Opdatering af licenserAfventerI går, 14:45
+
+
C
+ Cafe Møller +
+
NetværksnedbrudKritiskI går, 09:12
+
+
+
+
+
+
System Status
+
+
+
+ Server Load + 24% +
+
+
+
+
+
+
+
+
+ Database + 56% +
+
+
+
+
+
+
+
+
+ Storage + 89% +
+
+
+
+
+
+ +
+
+
+ + System backup kører i nat kl. 03:00 +
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/02_dark/customers.html b/static/design_templates/02_dark/customers.html new file mode 100644 index 0000000..f5fc4b6 --- /dev/null +++ b/static/design_templates/02_dark/customers.html @@ -0,0 +1,298 @@ + + + + + + BMC Hub - Dark Customers + + + + + + + + +
+
+
+

Kundeoversigt

+

Administrer kunder og abonnementer

+
+
+ + +
+
+ +
+
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VirksomhedKontaktCVRStatusUdstyrHandling
+
+
A
+
+
Advokatgruppen A/S
+
København K
+
+
+
+
Jens Jensen
+
jens@advokat.dk
+
12345678Aktiv + Firewall + Switch + + +
+
+
B
+
+
Byg & Bo ApS
+
Aarhus C
+
+
+
+
Mette Hansen
+
mh@bygbo.dk
+
87654321Aktiv + Router + + +
+
+
C
+
+
Cafe Møller
+
Odense M
+
+
+
+
Peter Møller
+
pm@cafe.dk
+
11223344Afventer + - + + +
+
+
D
+
+
Dansk Design Hus
+
København Ø
+
+
+
+
Lars Larsen
+
ll@design.dk
+
44332211Aktiv + Firewall + +4 + + +
+
+ +
+
Viser 1-4 af 124 kunder
+ +
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/02_dark/index.html b/static/design_templates/02_dark/index.html new file mode 100644 index 0000000..e4432f3 --- /dev/null +++ b/static/design_templates/02_dark/index.html @@ -0,0 +1,267 @@ + + + + + + BMC Hub - Dark Dashboard + + + + + + + + +
+
+
+

Dashboard

+

System overblik og status

+
+
+ + +
+
+ +
+
+
+
Aktive Kunder
+
124
+
12% denne måned
+
+
+
+
+
Hardware
+
856
+
Enheder online
+
+
+
+
+
Support
+
12
+
Åbne sager
+
+
+
+
+
Omsætning
+
450k
+
DKK denne måned
+
+
+
+ +
+
+
+
Seneste Hændelser
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
HændelseKundeTidStatus
Firewall konfiguration ændretAdvokatgruppen A/S10:23Fuldført
Licens fornyelseByg & Bo ApSI gårAfventer
VPN forbindelse tabtCafe MøllerI gårFejl
Ny bruger oprettetDansk Design Hus2 dage sidenFuldført
+
+
+
+
+
Server Status
+ +
+
+ CPU Usage + 24% +
+
+
+
+
+ +
+
+ Memory + 56% +
+
+
+
+
+ +
+
+ Storage + 89% +
+
+
+
+
+ +
+
+ + Systemet kører optimalt. Ingen kritiske fejl fundet. +
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/03_swiss/customers.html b/static/design_templates/03_swiss/customers.html new file mode 100644 index 0000000..6f73562 --- /dev/null +++ b/static/design_templates/03_swiss/customers.html @@ -0,0 +1,285 @@ + + + + + + BMC Hub - Swiss Customers + + + + + + + + +
+
+
+

KUNDER

+
+
+ + +
+
+ +
+
+ FILTER: + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VirksomhedKontaktpersonCVRStatusHardwareHandling
+
Advokatgruppen A/S
+
København K
+
+
Jens Jensen
+
jens@advokat.dk
+
12345678Aktiv + FIREWALL, SWITCH + + +
+
Byg & Bo ApS
+
Aarhus C
+
+
Mette Hansen
+
mh@bygbo.dk
+
87654321Aktiv + ROUTER + + +
+
Cafe Møller
+
Odense M
+
+
Peter Møller
+
pm@cafe.dk
+
11223344Afventer + - + + +
+
Dansk Design Hus
+
København Ø
+
+
Lars Larsen
+
ll@design.dk
+
44332211Aktiv + FIREWALL, AP x4 + + +
+ +
+
VISER 1-4 AF 124 KUNDER
+
+ + + + + +
+
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/03_swiss/index.html b/static/design_templates/03_swiss/index.html new file mode 100644 index 0000000..a61d7d2 --- /dev/null +++ b/static/design_templates/03_swiss/index.html @@ -0,0 +1,278 @@ + + + + + + BMC Hub - Swiss Dashboard + + + + + + + + +
+
+
+

OVERSIGT

+
+
+ + +
+
+ +
+
+
+
+
124
+
Aktive Kunder
+
+
+
+
+
856
+
Hardware Enheder
+
+
+
+
+
12
+
Support Sager
+
+
+
+
+
450k
+
Månedlig Omsætning
+
+
+
+ +
+
+

SENESTE AKTIVITETER

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KundeHandlingStatusTid
Advokatgruppen A/SFirewall konfiguration
Fuldført
10:23
Byg & Bo ApSLicens fornyelse
Afventer
I går
Cafe MøllerNetværksnedbrud
Kritisk
I går
Dansk Design HusNy bruger oprettet
Fuldført
2 dage siden
+
+
+

HURTIG ADGANG

+
+ + + +
+
NOTER
+

Husk at opdatere firewall regler for kunde #124 inden fredag.

+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/04_soft/customers.html b/static/design_templates/04_soft/customers.html new file mode 100644 index 0000000..664d1bd --- /dev/null +++ b/static/design_templates/04_soft/customers.html @@ -0,0 +1,306 @@ + + + + + + BMC Hub - Soft Customers + + + + + + + + +
+
+
+

Kunder

+

Administrer dine kunder og deres services

+
+
+ + +
+
+ +
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VirksomhedKontaktpersonCVR NummerStatusHardware
+
+
+ +
+
+
Advokatgruppen A/S
+
København K
+
+
+
+
Jens Jensen
+
jens@advokat.dk
+
12345678Aktiv + Firewall + Switch + + +
+
+
+ +
+
+
Byg & Bo ApS
+
Aarhus C
+
+
+
+
Mette Hansen
+
mh@bygbo.dk
+
87654321Aktiv + Router + + +
+
+
+ +
+
+
Cafe Møller
+
Odense M
+
+
+
+
Peter Møller
+
pm@cafe.dk
+
11223344Afventer + - + + +
+
+
+ +
+
+
Dansk Design Hus
+
København Ø
+
+
+
+
Lars Larsen
+
ll@design.dk
+
44332211Aktiv + Firewall + AP x4 + + +
+ +
+ +
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/04_soft/index.html b/static/design_templates/04_soft/index.html new file mode 100644 index 0000000..922edff --- /dev/null +++ b/static/design_templates/04_soft/index.html @@ -0,0 +1,321 @@ + + + + + + BMC Hub - Soft Dashboard + + + + + + + + +
+
+
+

Godmorgen, Christian! 👋

+

Her er dit overblik for i dag

+
+
+ + +
+
+ +
+
+
+
+ +
+

124

+

Aktive Kunder

+
+
+
+
+
+ +
+

856

+

Hardware Enheder

+
+
+
+
+
+ +
+

12

+

Support Sager

+
+
+
+
+
+ +
+

450k

+

Månedlig Omsætning

+
+
+
+ +
+
+
+
+
Seneste Aktiviteter
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KundeHandlingStatusTid
+
+
+ +
+ Advokatgruppen A/S +
+
Firewall konfigurationFuldført10:23
+
+
+ +
+ Byg & Bo ApS +
+
Licens fornyelseAfventerI går
+
+
+ +
+ Cafe Møller +
+
NetværksnedbrudKritiskI går
+
+
+
+
+
System Status
+ +
+
+ CPU Usage + 24% +
+
+
+
+
+ +
+
+ Memory + 56% +
+
+
+
+
+ +
+
+
+ + Alle systemer kører normalt. Ingen nedetid registreret de sidste 24 timer. +
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/05_compact/customers.html b/static/design_templates/05_compact/customers.html new file mode 100644 index 0000000..05c5088 --- /dev/null +++ b/static/design_templates/05_compact/customers.html @@ -0,0 +1,226 @@ + + + + + + BMC Hub - Compact Customers + + + + + + + + +
+
+
+
Kundeoversigt
+
+
+ + +
+
+ +
+
+
+ + + + +
+
Total: 124 kunder
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDVirksomhedKontaktpersonCVRStatusHardwareHandling
101 +
Advokatgruppen A/S
+
København K
+
+
Jens Jensen
+
jens@advokat.dk
+
12345678Aktiv + Firewall + Switch + + +
102 +
Byg & Bo ApS
+
Aarhus C
+
+
Mette Hansen
+
mh@bygbo.dk
+
87654321Aktiv + Router + + +
103 +
Cafe Møller
+
Odense M
+
+
Peter Møller
+
pm@cafe.dk
+
11223344Afventer + - + + +
104 +
Dansk Design Hus
+
København Ø
+
+
Lars Larsen
+
ll@design.dk
+
44332211Aktiv + Firewall + AP x4 + + +
+
+ +
+
+ + + \ No newline at end of file diff --git a/static/design_templates/05_compact/index.html b/static/design_templates/05_compact/index.html new file mode 100644 index 0000000..731567e --- /dev/null +++ b/static/design_templates/05_compact/index.html @@ -0,0 +1,269 @@ + + + + + + BMC Hub - Compact Dashboard + + + + + + + + +
+
+
+
Dashboard
+ System Status: Online +
+
+ + +
+
+ +
+
+
+
+
+
+
Kunder
+
124
+
+ +
+
+
+
+
+
+
+
+
+
Hardware
+
856
+
+ +
+
+
+
+
+
+
+
+
+
Support
+
12
+
+ +
+
+
+
+
+
+
+
+
+
Omsætning
+
450k
+
+ +
+
+
+
+
+ +
+
+
+
+ Seneste Aktiviteter + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDKundeHandlingStatusTid
#1023Advokatgruppen A/SFirewall konfigurationFuldført10:23
#1022Byg & Bo ApSLicens fornyelseAfventerI går
#1021Cafe MøllerNetværksnedbrudKritiskI går
#1020Dansk Design HusNy bruger oprettetFuldført2 dage siden
#1019Elektriker MadsenVPN opsætningFuldført2 dage siden
+
+
+
+
+
+
System Ressourcer
+
+
+
+ CPU + 24% +
+
+
+
+
+
+
+ RAM + 56% +
+
+
+
+
+
+
+ Disk + 89% +
+
+
+
+
+
+
+ + +
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/06_glass/customers.html b/static/design_templates/06_glass/customers.html new file mode 100644 index 0000000..cf16714 --- /dev/null +++ b/static/design_templates/06_glass/customers.html @@ -0,0 +1,292 @@ + + + + + + BMC Hub - Glass Customers + + + + + + +
+
+ + + +
+
+
+

Kunder

+

Administrer dine kunder

+
+
+ + +
+
+ +
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VirksomhedKontaktCVRStatusHardware
+
+
A
+
+
Advokatgruppen A/S
+
København K
+
+
+
+
Jens Jensen
+
jens@advokat.dk
+
12345678Aktiv + Firewall + Switch + + +
+
+
B
+
+
Byg & Bo ApS
+
Aarhus C
+
+
+
+
Mette Hansen
+
mh@bygbo.dk
+
87654321Aktiv + Router + + +
+
+
C
+
+
Cafe Møller
+
Odense M
+
+
+
+
Peter Møller
+
pm@cafe.dk
+
11223344Afventer + - + + +
+ +
+ +
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/06_glass/index.html b/static/design_templates/06_glass/index.html new file mode 100644 index 0000000..1efe738 --- /dev/null +++ b/static/design_templates/06_glass/index.html @@ -0,0 +1,295 @@ + + + + + + BMC Hub - Glass Dashboard + + + + + + +
+
+ + + +
+
+
+

Dashboard

+

Velkommen tilbage, Christian

+
+
+ + +
+
+ +
+
+
+
+ Aktive Kunder + +
+

124

+ +12% denne måned +
+
+
+
+
+ Hardware + +
+

856

+ Enheder online +
+
+
+
+
+ Support + +
+

12

+ 3 kræver handling +
+
+
+
+
+ Omsætning + +
+

450k

+ Over budget +
+
+
+ +
+
+
+

Seneste Aktiviteter

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KundeHandlingStatusTid
Advokatgruppen A/SFirewall konfigurationFuldført10:23
Byg & Bo ApSLicens fornyelseAfventerI går
Cafe MøllerNetværksnedbrudKritiskI går
+
+
+
+
+

System Status

+ +
+
+ CPU Load + 24% +
+
+
+
+
+ +
+
+ Memory + 56% +
+
+
+
+
+ +
+
+ + Alle systemer kører optimalt. +
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/07_material/customers.html b/static/design_templates/07_material/customers.html new file mode 100644 index 0000000..03c764c --- /dev/null +++ b/static/design_templates/07_material/customers.html @@ -0,0 +1,346 @@ + + + + + + BMC Hub - Material Customers + + + + + + + + + +
+
+

Kunder

+ +
+ +
+
+ +
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VirksomhedKontaktpersonCVRStatusHardware
+
+
A
+
+
Advokatgruppen A/S
+
København K
+
+
+
+
Jens Jensen
+
jens@advokat.dk
+
12345678Aktiv + Firewall + Switch + + +
+
+
B
+
+
Byg & Bo ApS
+
Aarhus C
+
+
+
+
Mette Hansen
+
mh@bygbo.dk
+
87654321Aktiv + Router + + +
+
+
C
+
+
Cafe Møller
+
Odense M
+
+
+
+
Peter Møller
+
pm@cafe.dk
+
11223344Afventer + - + + +
+ +
+ +
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/07_material/index.html b/static/design_templates/07_material/index.html new file mode 100644 index 0000000..c8c62ac --- /dev/null +++ b/static/design_templates/07_material/index.html @@ -0,0 +1,350 @@ + + + + + + BMC Hub - Material Dashboard + + + + + + + + + +
+
+

Dashboard

+ +
+ + +
+
+ +
+
+
+
+ + Kunder +
+

124

+ Aktive abonnementer +
+
+
+
+
+ + Hardware +
+

856

+ Enheder registreret +
+
+
+
+
+ + Support +
+

12

+ Åbne sager +
+
+
+
+
+ + Omsætning +
+

450k

+ Denne måned +
+
+
+ +
+
+
+
+
Seneste aktivitet
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KundeHandlingStatusTid
+
+
A
+ Advokatgruppen A/S +
+
Firewall konfigurationFuldført10:23
+
+
B
+ Byg & Bo ApS +
+
Licens fornyelseAfventerI går
+
+
C
+ Cafe Møller +
+
NetværksnedbrudKritiskI går
+
+
+
+
+
System Status
+ +
+
+ CPU + 24% +
+
+
+
+
+ +
+
+ RAM + 56% +
+
+
+
+
+ +
+
+ + System backup kører kl. 03:00 +
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/08_horizontal_clean/customers.html b/static/design_templates/08_horizontal_clean/customers.html new file mode 100644 index 0000000..da8f7e3 --- /dev/null +++ b/static/design_templates/08_horizontal_clean/customers.html @@ -0,0 +1,225 @@ + + + + + + BMC Hub - Horizontal Clean Customers + + + + + + + + +
+
+

Kunder

+
+ + +
+
+ +
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VirksomhedKontaktCVRStatusHardware
+
+
AG
+
+
Advokatgruppen A/S
+
København K
+
+
+
+
Jens Jensen
+
jens@advokat.dk
+
12345678Aktiv + Firewall + Switch + + +
+
+
BB
+
+
Byg & Bo ApS
+
Aarhus C
+
+
+
+
Mette Hansen
+
mh@bygbo.dk
+
87654321Aktiv + Router + + +
+
+
CM
+
+
Cafe Møller
+
Odense M
+
+
+
+
Peter Møller
+
pm@cafe.dk
+
11223344Afventer + - + + +
+
+ +
+
+ + + \ No newline at end of file diff --git a/static/design_templates/08_horizontal_clean/index.html b/static/design_templates/08_horizontal_clean/index.html new file mode 100644 index 0000000..61e16f2 --- /dev/null +++ b/static/design_templates/08_horizontal_clean/index.html @@ -0,0 +1,286 @@ + + + + + + BMC Hub - Horizontal Clean + + + + + + + + +
+
+

Dashboard

+
+ + +
+
+ +
+
+
+
+
+
Aktive Kunder
+
124
+
+
+ +
+
+
+ 12% stigning +
+
+
+
+
+
+
+
Hardware
+
856
+
+
+ +
+
+
+ Enheder online +
+
+
+
+
+
+
+
Support
+
12
+
+
+ +
+
+
+ 3 kræver handling +
+
+
+
+
+
+
+
Omsætning
+
450k
+
+
+ +
+
+
+ +5% vs sidste md. +
+
+
+
+ +
+
+
+
+
Seneste Aktiviteter
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KundeHandlingStatusTid
Advokatgruppen A/SFirewall konfigurationFuldført10:23
Byg & Bo ApSLicens fornyelseAfventerI går
Cafe MøllerNetværksnedbrudKritiskI går
Dansk Design HusNy bruger oprettetFuldført2 dage siden
+
+
+
+
+
+
System Status
+ +
+
+ CPU USAGE + 24% +
+
+
+
+
+ +
+
+ MEMORY + 56% +
+
+
+
+
+ +
+
+ STORAGE + 89% +
+
+
+
+
+ +
+ + Alle systemer operationelle +
+
+
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/09_horizontal_dark/customers.html b/static/design_templates/09_horizontal_dark/customers.html new file mode 100644 index 0000000..d434481 --- /dev/null +++ b/static/design_templates/09_horizontal_dark/customers.html @@ -0,0 +1,259 @@ + + + + + + BMC Hub - Horizontal Dark Customers + + + + + + + + +
+
+
+

Kunder

+
+
+ + +
+
+ +
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VirksomhedKontaktCVRStatusHardwareHandling
+
Advokatgruppen A/S
+
København K
+
+
Jens Jensen
+
jens@advokat.dk
+
12345678AKTIV + FIREWALL + SWITCH + + +
+
Byg & Bo ApS
+
Aarhus C
+
+
Mette Hansen
+
mh@bygbo.dk
+
87654321AKTIV + ROUTER + + +
+
Cafe Møller
+
Odense M
+
+
Peter Møller
+
pm@cafe.dk
+
11223344AFVENTER + - + + +
+
Dansk Design Hus
+
København Ø
+
+
Lars Larsen
+
ll@design.dk
+
44332211AKTIV + FIREWALL + +4 + + +
+ +
+ +
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/09_horizontal_dark/index.html b/static/design_templates/09_horizontal_dark/index.html new file mode 100644 index 0000000..108e663 --- /dev/null +++ b/static/design_templates/09_horizontal_dark/index.html @@ -0,0 +1,254 @@ + + + + + + BMC Hub - Horizontal Dark + + + + + + + + +
+
+
+

Oversigt

+
+
+ +
+
+ +
+
+
+
Aktive Kunder
+
124
+
+
+
+
+
Hardware
+
856
+
+
+
+
+
Support
+
12
+
+
+
+
+
Omsætning
+
450k
+
+
+
+ +
+
+
Seneste Log
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KundeHandlingStatusTid
Advokatgruppen A/SFirewall konfigurationFULDFØRT10:23
Byg & Bo ApSLicens fornyelseAFVENTERI går
Cafe MøllerNetværksnedbrudKRITISKI går
Dansk Design HusNy bruger oprettetFULDFØRT2 dage siden
+
+
+
+
Server Status
+ +
+
+ CPU LOAD + 24% +
+
+
+
+
+ +
+
+ MEMORY + 56% +
+
+
+
+
+ +
+
+ STORAGE + 89% +
+
+
+
+
+ +
+ LAST BACKUP + Today, 03:00 AM +
+
+
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/11_material_top_blue/customers.html b/static/design_templates/11_material_top_blue/customers.html new file mode 100644 index 0000000..532f3bc --- /dev/null +++ b/static/design_templates/11_material_top_blue/customers.html @@ -0,0 +1,328 @@ + + + + + + BMC Hub - Material Blue Customers + + + + + + + +
+
+
+ +
+ BMC Hub +
+ + + +
+ + +
+ + Christian +
+
+
+ +
+
+

Kunder

+ +
+ +
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VirksomhedKontaktpersonCVRStatusHardware
+
+
A
+
+
Advokatgruppen A/S
+
København K
+
+
+
+
Jens Jensen
+
jens@advokat.dk
+
12345678Aktiv + Firewall + Switch + + +
+
+
B
+
+
Byg & Bo ApS
+
Aarhus C
+
+
+
+
Mette Hansen
+
mh@bygbo.dk
+
87654321Aktiv + Router + + +
+
+
C
+
+
Cafe Møller
+
Odense M
+
+
+
+
Peter Møller
+
pm@cafe.dk
+
11223344Afventer + - + + +
+ +
+ +
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/11_material_top_blue/index.html b/static/design_templates/11_material_top_blue/index.html new file mode 100644 index 0000000..a2d0b04 --- /dev/null +++ b/static/design_templates/11_material_top_blue/index.html @@ -0,0 +1,331 @@ + + + + + + BMC Hub - Material Blue + + + + + + + +
+
+
+ +
+ BMC Hub +
+ + + +
+ + +
+ + Christian +
+
+
+ +
+
+

Dashboard

+ +
+ +
+
+
+
+ + Kunder +
+

124

+ Aktive abonnementer +
+
+
+
+
+ + Hardware +
+

856

+ Enheder registreret +
+
+
+
+
+ + Support +
+

12

+ Åbne sager +
+
+
+
+
+ + Omsætning +
+

450k

+ Denne måned +
+
+
+ +
+
+
+
+
Seneste aktivitet
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KundeHandlingStatusTid
+
+
A
+ Advokatgruppen A/S +
+
Firewall konfigurationFuldført10:23
+
+
B
+ Byg & Bo ApS +
+
Licens fornyelseAfventerI går
+
+
C
+ Cafe Møller +
+
NetværksnedbrudKritiskI går
+
+
+
+
+
System Status
+ +
+
+ CPU + 24% +
+
+
+
+
+ +
+
+ RAM + 56% +
+
+
+
+
+ +
+
+ + System backup kører kl. 03:00 +
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/static/design_templates/index.html b/static/design_templates/index.html new file mode 100644 index 0000000..3994ac8 --- /dev/null +++ b/static/design_templates/index.html @@ -0,0 +1,213 @@ + + + + + + BMC Hub - Design Templates + + + + +
+
+

BMC Hub Design Templates

+

Choose a style below to preview the dashboard and customer overview pages.

+
+ +
+ +
+
+
+ NORDIC +
+
+
01. Nordic Minimalist
+

Clean, airy, white & deep blue. Professional, calm, and spacious layout.

+ +
+
+
+ + +
+
+
+ DARK +
+
+
02. Dark Professional
+

High contrast dark theme with purple accents. Developer-focused and modern.

+ +
+
+
+ + +
+
+
+ SWISS +
+
+
03. Swiss Grid
+

Bold typography, grid-based, black & white with red accent. Sharp and editorial.

+ +
+
+
+ + +
+
+
+ SOFT +
+
+
04. Soft Gradient
+

Rounded corners, soft shadows, and gradients. Friendly and approachable.

+ +
+
+
+ + +
+
+
+ COMPACT +
+
+
05. Compact Utility
+

Data-dense, utility focused. Maximum information density.

+ +
+
+
+ + +
+
+
+ GLASS +
+
+
06. Glassmorphism
+

Floating sidebar, blurred backgrounds, and gradients. Modern and trendy.

+ +
+
+
+ + +
+
+
+ MATERIAL +
+
+
07. Material 3
+

Google's Material Design 3. Navigation Rail, pills, and pastel tones.

+ +
+
+
+ + +
+
+
+ TOP NAV +
+
+
08. Horizontal Clean
+

Classic top navigation bar. Clean, white, and blue. Standard SaaS layout.

+ +
+
+
+ + +
+
+
+ CYBER +
+
+
09. Horizontal Dark
+

Top navigation with a dark, cyberpunk/tech aesthetic. Neon accents.

+ +
+
+
+ + +
+
+
+ NORDIC TOP +
+
+
10. Nordic Topbar
+

The clean Nordic style but with a top navigation bar instead of sidebar.

+ +
+
+
+ + +
+
+
+ MAT BLUE +
+
+
11. Material Blue
+

Material 3 with top navigation, blue theme, and custom profile pill.

+ +
+
+
+
+
+ + \ No newline at end of file diff --git a/static/index.html b/static/index.html deleted file mode 100644 index 21fa493..0000000 --- a/static/index.html +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - BMC Hub - - - - - -
-
-
-

Velkommen til BMC Hub

-

Central management system for BMC Networks

-
-
- -
-
-
-
-
👥 Customers
-

Manage customer database

- View API -
-
-
-
-
-
-
🖥️ Hardware
-

Track customer hardware

- View API -
-
-
-
-
-
-
📊 System
-

Health and configuration

- Health Check -
-
-
-
- -
-
-
-
-
📖 API Documentation
-

Explore the complete API documentation

- OpenAPI Docs - ReDoc -
-
-
-
-
- - - -