From 770f822fc65000a7f47ac4d7ec9ea16f6ceef101 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 6 May 2026 07:01:43 +0200 Subject: [PATCH] feat: Implement bug reporting feature with screenshot support - Added a new modal for reporting bugs, including fields for describing the issue and attaching optional files. - Integrated automatic screenshot capture functionality when the bug report modal is opened. - Created a new API endpoint for submitting bug reports, including validation and rate limiting. - Added database migration for tracking bug report submissions. - Updated frontend scripts to handle bug report submissions and display status messages. - Enhanced contact search functionality with improved error handling and backward compatibility. - Introduced a new button in the UI for accessing the bug report modal. --- .env.example | 5 + .env.prod.example | 5 + app/contacts/frontend/contacts.html | 21 +- app/core/config.py | 5 + app/customers/backend/views.py | 6 +- app/customers/frontend/customer_detail.html | 91 ++++ app/economy/backend/router.py | 218 +++++++- app/economy/frontend/time_queue.html | 9 +- app/models/schemas.py | 5 + app/modules/locations/backend/router.py | 175 ++++-- app/modules/locations/frontend/views.py | 77 +++ app/modules/locations/models/schemas.py | 18 +- app/modules/locations/templates/detail.html | 497 ++++++++++++++++-- app/modules/locations/templates/list.html | 154 +++++- app/modules/orders/backend/router.py | 117 ++++- app/modules/orders/templates/create.html | 84 ++- app/modules/orders/templates/detail.html | 82 ++- app/modules/orders/templates/list.html | 58 ++ app/modules/telefoni/backend/router.py | 49 +- app/modules/telefoni/templates/log.html | 202 +------ app/products/backend/router.py | 320 ++++++++--- app/products/frontend/list.html | 72 ++- app/shared/frontend/base.html | 11 - app/shared/frontend/bug_report_modal.html | 8 +- app/timetracking/frontend/wizard2.html | 57 ++ main.py | 48 -- ...184_locations_contacts_related_contact.sql | 9 + .../185_locations_contacts_unique_related.sql | 26 + .../186_customers_economic_pricing_fields.sql | 16 + .../187_customers_standard_hourly_rate.sql | 7 + static/js/bug-report.js | 55 +- 31 files changed, 1994 insertions(+), 513 deletions(-) create mode 100644 migrations/184_locations_contacts_related_contact.sql create mode 100644 migrations/185_locations_contacts_unique_related.sql create mode 100644 migrations/186_customers_economic_pricing_fields.sql create mode 100644 migrations/187_customers_standard_hourly_rate.sql diff --git a/.env.example b/.env.example index cb1da34..2134eab 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,11 @@ API_HOST=0.0.0.0 API_PORT=8001 # Changed from 8000 to avoid conflicts with other services ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Docker) +# Customer default economics (used as fallback defaults in customer detail) +CUSTOMER_DEFAULT_MARGIN_PERCENT=20.0 +CUSTOMER_DEFAULT_INVOICE_FEE=49.0 +CUSTOMER_DEFAULT_HOURLY_RATE=1200.0 + # FirmaAPI (CVR company lookup) FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1 FIRMAAPI_API_KEY= diff --git a/.env.prod.example b/.env.prod.example index 2f636c4..0ee8ddf 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -44,6 +44,11 @@ API_HOST=0.0.0.0 API_PORT=8000 API_RELOAD=false +# Customer default economics (used as fallback defaults in customer detail) +CUSTOMER_DEFAULT_MARGIN_PERCENT=20.0 +CUSTOMER_DEFAULT_INVOICE_FEE=49.0 +CUSTOMER_DEFAULT_HOURLY_RATE=1200.0 + # FirmaAPI (CVR company lookup) FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1 FIRMAAPI_API_KEY= diff --git a/app/contacts/frontend/contacts.html b/app/contacts/frontend/contacts.html index 22fd7f9..b3464cf 100644 --- a/app/contacts/frontend/contacts.html +++ b/app/contacts/frontend/contacts.html @@ -160,8 +160,8 @@ .contacts-table-wrap { border: 1px solid rgba(15, 76, 117, 0.12); border-radius: 12px; - overflow-x: auto; - overflow-y: visible; + max-height: min(68vh, 780px); + overflow: auto; } .contacts-table { @@ -813,6 +813,7 @@ let searchQuery = ''; let totalContacts = 0; let searchTimeout = null; let currentRequestController = null; +let lastLoadedQueryKey = ''; let availableCompanies = []; let selectedCompanyIds = new Set(); let currentContactsData = []; @@ -940,6 +941,12 @@ async function loadContacts() { params.append('is_active', 'false'); } + const queryKey = `${currentPage}|${pageSize}|${searchQuery}|${currentFilter}`; + if (queryKey === lastLoadedQueryKey) { + return; + } + lastLoadedQueryKey = queryKey; + const response = await fetch(`/api/v1/contacts?${params}`, { signal: currentRequestController.signal }); const data = await response.json(); @@ -982,11 +989,9 @@ function displayContacts(contacts) { const companyCount = contact.company_count || 0; const companyNames = contact.company_names || []; - const fallbackCompany = (contact.user_company || '').trim(); - const companyDisplay = companyNames.length > 0 + const companyDisplay = companyNames.length > 0 ? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '') - : (fallbackCompany || '-'); - const effectiveCompanyCount = companyCount > 0 ? companyCount : (fallbackCompany ? 1 : 0); + : '-'; const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim(); const preferredPhone = contact.mobile || contact.phone || ''; const hasEmail = !!contact.email; @@ -996,7 +1001,7 @@ function displayContacts(contacts) { const safeEmail = escapeHtml(contact.email || '-'); const safeTitle = escapeHtml(contact.title || '-'); const safePhone = escapeHtml(preferredPhone || '-'); - const companiesTitle = escapeHtml(companyNames.length ? companyNames.join(', ') : fallbackCompany); + const companiesTitle = escapeHtml(companyNames.join(', ')); const updatedAt = formatContactDate(contact.updated_at || contact.created_at); return ` @@ -1035,7 +1040,7 @@ function displayContacts(contacts) { ${safeTitle} - ${effectiveCompanyCount} + ${companyCount} ${companyDisplay !== '-' ? '
' + escapeHtml(companyDisplay) + '
' : ''} diff --git a/app/core/config.py b/app/core/config.py index d2e304d..b42e113 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -160,6 +160,11 @@ class Settings(BaseSettings): TIMETRACKING_AUTO_ROUND: bool = True TIMETRACKING_ROUND_INCREMENT: float = 0.5 TIMETRACKING_ROUND_METHOD: str = "up" # "up", "down", "nearest" + + # Customer economic defaults + CUSTOMER_DEFAULT_MARGIN_PERCENT: float = 20.0 + CUSTOMER_DEFAULT_INVOICE_FEE: float = 49.0 + CUSTOMER_DEFAULT_HOURLY_RATE: float = 1200.0 # Time Tracking Module Safety Flags TIMETRACKING_VTIGER_READ_ONLY: bool = True diff --git a/app/customers/backend/views.py b/app/customers/backend/views.py index 8f85e70..880a307 100644 --- a/app/customers/backend/views.py +++ b/app/customers/backend/views.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Request from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse +from app.core.config import settings router = APIRouter() templates = Jinja2Templates(directory="app") @@ -20,7 +21,10 @@ async def customer_detail_page(request: Request, customer_id: int): """ return templates.TemplateResponse("customers/frontend/customer_detail.html", { "request": request, - "customer_id": customer_id + "customer_id": customer_id, + "customer_default_margin_percent": settings.CUSTOMER_DEFAULT_MARGIN_PERCENT, + "customer_default_invoice_fee": settings.CUSTOMER_DEFAULT_INVOICE_FEE, + "customer_default_hourly_rate": settings.CUSTOMER_DEFAULT_HOURLY_RATE, }) diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index a2090b5..c46f440 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -443,6 +443,26 @@ EAN-nummer - +
+ Standard avance + - +
+
+ Standard timepris + - +
+
+ Særlig fragtpris + - +
+
+ Leverandørservice + - +
+
+ Faktureringsgebyr + - +
Spærret - @@ -1022,6 +1042,43 @@
+ + +
+
+ Økonomiske standarder +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
Sæt 0 for at slå gebyr fra på ordren.
+
+ +
+
+ + +
+
@@ -1319,6 +1376,9 @@ diff --git a/app/products/backend/router.py b/app/products/backend/router.py index 38ee908..dbb7450 100644 --- a/app/products/backend/router.py +++ b/app/products/backend/router.py @@ -235,6 +235,178 @@ def _score_apigw_product(product: Dict[str, Any], normalized_query: str, tokens: return score +def _normalize_lookup_code(raw: Optional[str]) -> str: + return "".join(ch for ch in str(raw or "") if ch.isalnum()).lower() + + +def _find_local_product_by_lookup(code: str) -> Optional[Dict[str, Any]]: + normalized = _normalize_lookup_code(code) + if not normalized: + return None + + query = """ + SELECT * + FROM products + WHERE deleted_at IS NULL + AND ( + LOWER(REGEXP_REPLACE(COALESCE(ean, ''), '[^a-zA-Z0-9]', '', 'g')) = %s + OR LOWER(REGEXP_REPLACE(COALESCE(sku_internal, ''), '[^a-zA-Z0-9]', '', 'g')) = %s + ) + ORDER BY + CASE + WHEN status = 'active' THEN 0 + ELSE 1 + END, + id ASC + LIMIT 1 + """ + return execute_query_single(query, (normalized, normalized)) + + +def _pick_best_apigw_match(products: List[Dict[str, Any]], query: str) -> Optional[Dict[str, Any]]: + if not products: + return None + + normalized_query = _normalize_lookup_code(query) + if not normalized_query: + return products[0] + + exact_matches: List[Dict[str, Any]] = [] + sku_matches: List[Dict[str, Any]] = [] + for product in products: + ean_norm = _normalize_lookup_code(product.get("ean")) + sku_norm = _normalize_lookup_code(product.get("sku")) + if ean_norm and ean_norm == normalized_query: + exact_matches.append(product) + elif sku_norm and sku_norm == normalized_query: + sku_matches.append(product) + + if exact_matches: + return exact_matches[0] + if sku_matches: + return sku_matches[0] + return products[0] + + +def _get_customer_margin_percent(customer_id: Optional[int]) -> Optional[float]: + if not customer_id: + return None + + customer = execute_query_single( + "SELECT standard_margin_percent FROM customers WHERE id = %s", + (customer_id,), + ) + if not customer: + return None + + margin_raw = customer.get("standard_margin_percent") + if margin_raw is None: + return None + + try: + return float(margin_raw) + except (TypeError, ValueError): + return None + + +def _calculate_sales_price_with_margin(base_price: Any, margin_percent: Optional[float]) -> Optional[float]: + if base_price is None: + return None + try: + base = float(base_price) + except (TypeError, ValueError): + return None + + if margin_percent is None: + return base + + price = base * (1 + (float(margin_percent) / 100.0)) + return round(price, 2) + + +def _import_apigw_product_to_local(payload: Dict[str, Any], customer_id: Optional[int] = None) -> Dict[str, Any]: + product = payload.get("product") or payload + name = (product.get("product_name") or product.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="product_name is required") + + supplier_code = product.get("supplier_code") + sku = product.get("sku") + sku_internal = f"{supplier_code}:{sku}" if supplier_code and sku else sku + + if sku_internal: + existing_by_sku = execute_query_single( + "SELECT * FROM products WHERE sku_internal = %s AND deleted_at IS NULL", + (sku_internal,) + ) + if existing_by_sku: + _upsert_product_supplier(existing_by_sku["id"], product, source="gateway") + return existing_by_sku + + ean = (product.get("ean") or "").strip() + if ean: + existing_by_ean = execute_query_single( + "SELECT * FROM products WHERE ean = %s AND deleted_at IS NULL", + (ean,) + ) + if existing_by_ean: + _upsert_product_supplier(existing_by_ean["id"], product, source="gateway") + return existing_by_ean + + margin_percent = _get_customer_margin_percent(customer_id) + sales_price = _calculate_sales_price_with_margin(product.get("price"), margin_percent) + supplier_price = product.get("price") + + insert_query = """ + INSERT INTO products ( + name, + short_description, + type, + status, + sku_internal, + ean, + manufacturer, + supplier_name, + supplier_sku, + supplier_price, + supplier_currency, + supplier_stock, + sales_price, + vat_rate, + billable + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) + RETURNING * + """ + params = ( + name, + product.get("category"), + "hardware", + "active", + sku_internal, + ean or None, + product.get("manufacturer"), + product.get("supplier_name"), + sku, + supplier_price, + product.get("currency") or "DKK", + product.get("stock_qty"), + sales_price, + 25.00, + True, + ) + result = execute_query(insert_query, params) + created = result[0] if result else {} + if created: + _upsert_product_supplier(created["id"], product, source="gateway") + + if created and margin_percent is not None: + created["applied_customer_margin_percent"] = margin_percent + + return created + + @router.get("/products/apigateway/search", response_model=Dict[str, Any]) async def search_apigw_products( q: Optional[str] = Query(None), @@ -310,75 +482,93 @@ async def search_apigw_products( raise HTTPException(status_code=500, detail=str(e)) +@router.get("/products/search/apigateway-sync", response_model=Dict[str, Any]) +async def search_or_create_product_from_apigw( + code: str = Query(..., min_length=2), + auto_create: bool = Query(True), + customer_id: Optional[int] = Query(None), +): + """ + Local-first product lookup by EAN/SKU-like code. + If not found locally, search APIGateway and optionally auto-create locally from best match. + """ + search_code = (code or "").strip() + if not search_code: + raise HTTPException(status_code=400, detail="code is required") + + local_product = _find_local_product_by_lookup(search_code) + if local_product: + return { + "found": True, + "source": "local", + "created": False, + "query": search_code, + "product": local_product, + } + + timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS) + url = f"{_apigw_base_url()}/api/v1/products/search" + params = {"q": search_code, "per_page": 25} + + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url, headers=_apigw_headers(), params=params) as response: + if response.status >= 400: + detail = await _read_apigw_error(response) + raise HTTPException( + status_code=502, + detail=f"API Gateway product search failed ({response.status}): {detail}", + ) + data = await response.json() + except HTTPException: + raise + except asyncio.TimeoutError: + raise HTTPException(status_code=504, detail="API Gateway product search timed out") + except aiohttp.ClientError as e: + raise HTTPException(status_code=502, detail=f"API Gateway connection failed: {e}") + except Exception as e: + logger.error("❌ APIGW sync search failed: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + products = data.get("products") if isinstance(data, dict) else [] + products = products if isinstance(products, list) else [] + best_match = _pick_best_apigw_match(products, search_code) + if not best_match: + return { + "found": False, + "source": "apigateway", + "created": False, + "query": search_code, + "message": "Ingen match i APIGateway", + } + + if not auto_create: + return { + "found": True, + "source": "apigateway", + "created": False, + "query": search_code, + "product": best_match, + } + + created_or_existing = _import_apigw_product_to_local(best_match, customer_id=customer_id) + applied_margin = created_or_existing.get("applied_customer_margin_percent") if isinstance(created_or_existing, dict) else None + return { + "found": True, + "source": "apigateway", + "created": True, + "query": search_code, + "customer_id": customer_id, + "applied_customer_margin_percent": applied_margin, + "product": created_or_existing, + } + + @router.post("/products/apigateway/import", response_model=Dict[str, Any]) async def import_apigw_product(payload: Dict[str, Any]): """Import a single APIGW product into local catalog.""" try: - product = payload.get("product") or payload - name = (product.get("product_name") or product.get("name") or "").strip() - if not name: - raise HTTPException(status_code=400, detail="product_name is required") - - supplier_code = product.get("supplier_code") - sku = product.get("sku") - sku_internal = f"{supplier_code}:{sku}" if supplier_code and sku else sku - - if sku_internal: - existing = execute_query_single( - "SELECT * FROM products WHERE sku_internal = %s AND deleted_at IS NULL", - (sku_internal,) - ) - if existing: - _upsert_product_supplier(existing["id"], product, source="gateway") - return existing - - sales_price = product.get("price") - supplier_price = product.get("price") - - insert_query = """ - INSERT INTO products ( - name, - short_description, - type, - status, - sku_internal, - ean, - manufacturer, - supplier_name, - supplier_sku, - supplier_price, - supplier_currency, - supplier_stock, - sales_price, - vat_rate, - billable - ) VALUES ( - %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s - ) - RETURNING * - """ - params = ( - name, - product.get("category"), - "hardware", - "active", - sku_internal, - product.get("ean"), - product.get("manufacturer"), - product.get("supplier_name"), - sku, - supplier_price, - product.get("currency") or "DKK", - product.get("stock_qty"), - sales_price, - 25.00, - True, - ) - result = execute_query(insert_query, params) - created = result[0] if result else {} - if created: - _upsert_product_supplier(created["id"], product, source="gateway") - return created + return _import_apigw_product_to_local(payload) except HTTPException: raise except Exception as e: diff --git a/app/products/frontend/list.html b/app/products/frontend/list.html index df63ce6..05b82ff 100644 --- a/app/products/frontend/list.html +++ b/app/products/frontend/list.html @@ -210,7 +210,7 @@