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