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.
This commit is contained in:
parent
71f6372496
commit
770f822fc6
@ -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=
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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
|
||||
? 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) {
|
||||
<td class="text-muted col-title">${safeTitle}</td>
|
||||
<td class="col-companies">
|
||||
<span class="company-count-chip" title="${companiesTitle}">
|
||||
<i class="bi bi-building"></i>${effectiveCompanyCount}
|
||||
<i class="bi bi-building"></i>${companyCount}
|
||||
</span>
|
||||
${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''}
|
||||
</td>
|
||||
|
||||
@ -161,6 +161,11 @@ class Settings(BaseSettings):
|
||||
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
|
||||
TIMETRACKING_VTIGER_DRY_RUN: bool = True
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -443,6 +443,26 @@
|
||||
<span class="info-label">EAN-nummer</span>
|
||||
<span class="info-value" id="ean">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Standard avance</span>
|
||||
<span class="info-value" id="standardMarginPercent">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Standard timepris</span>
|
||||
<span class="info-value" id="standardHourlyRate">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Særlig fragtpris</span>
|
||||
<span class="info-value" id="specialFreightPrice">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Leverandørservice</span>
|
||||
<span class="info-value" id="supplierServiceEnrolled">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Faktureringsgebyr</span>
|
||||
<span class="info-value" id="invoiceFeeAmount">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Spærret</span>
|
||||
<span class="info-value" id="barred">-</span>
|
||||
@ -1023,6 +1043,43 @@
|
||||
<input type="text" class="form-control" id="editCity">
|
||||
</div>
|
||||
|
||||
<!-- Economic defaults -->
|
||||
<div class="col-12 mt-4">
|
||||
<h6 class="text-muted text-uppercase small fw-bold mb-3">
|
||||
<i class="bi bi-currency-exchange me-2"></i>Økonomiske standarder
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="editStandardMarginPercent" class="form-label">Standard avance (%)</label>
|
||||
<input type="number" class="form-control" id="editStandardMarginPercent" min="0" max="1000" step="0.01">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="editStandardHourlyRate" class="form-label">Standard timepris (DKK)</label>
|
||||
<input type="number" class="form-control" id="editStandardHourlyRate" min="0" step="0.01">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="editSpecialFreightPrice" class="form-label">Særlig fragtpris (DKK)</label>
|
||||
<input type="number" class="form-control" id="editSpecialFreightPrice" min="0" step="0.01">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="editInvoiceFeeAmount" class="form-label">Faktureringsgebyr (DKK)</label>
|
||||
<input type="number" class="form-control" id="editInvoiceFeeAmount" min="0" step="0.01">
|
||||
<div class="form-text">Sæt 0 for at slå gebyr fra på ordren.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 d-flex align-items-end">
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="editSupplierServiceEnrolled">
|
||||
<label class="form-check-label" for="editSupplierServiceEnrolled">
|
||||
Tilmeldt leverandørservice
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="col-12 mt-4">
|
||||
<div class="form-check form-switch">
|
||||
@ -1319,6 +1376,9 @@
|
||||
|
||||
<script>
|
||||
const customerId = parseInt(window.location.pathname.split('/').pop());
|
||||
const customerDefaultMarginPercent = Number({{ customer_default_margin_percent | tojson }} || 20);
|
||||
const customerDefaultInvoiceFee = Number({{ customer_default_invoice_fee | tojson }} || 49);
|
||||
const customerDefaultHourlyRate = Number({{ customer_default_hourly_rate | tojson }} || 1200);
|
||||
let customerData = null;
|
||||
let pipelineStages = [];
|
||||
let allTagsCache = [];
|
||||
@ -1674,6 +1734,22 @@ function displayCustomer(customer) {
|
||||
document.getElementById('vatZone').textContent = customer.vat_zone || '-';
|
||||
document.getElementById('currency').textContent = customer.currency_code || 'DKK';
|
||||
document.getElementById('ean').textContent = customer.ean || '-';
|
||||
const standardMargin = customer.standard_margin_percent ?? customerDefaultMarginPercent;
|
||||
const invoiceFee = customer.invoice_fee_amount ?? customerDefaultInvoiceFee;
|
||||
const standardHourlyRate = customer.standard_hourly_rate ?? customerDefaultHourlyRate;
|
||||
const freight = customer.special_freight_price;
|
||||
|
||||
document.getElementById('standardMarginPercent').textContent = `${Number(standardMargin).toFixed(2)} %`;
|
||||
document.getElementById('standardHourlyRate').textContent = `${Number(standardHourlyRate).toFixed(2)} DKK`;
|
||||
document.getElementById('specialFreightPrice').textContent = (freight === null || typeof freight === 'undefined')
|
||||
? '-'
|
||||
: `${Number(freight).toFixed(2)} DKK`;
|
||||
document.getElementById('supplierServiceEnrolled').innerHTML = customer.supplier_service_enrolled
|
||||
? '<span class="badge bg-success">Tilmeldt</span>'
|
||||
: '<span class="badge bg-secondary">Ikke tilmeldt</span>';
|
||||
document.getElementById('invoiceFeeAmount').textContent = Number(invoiceFee) === 0
|
||||
? '0,00 DKK (deaktiveret)'
|
||||
: `${Number(invoiceFee).toFixed(2)} DKK`;
|
||||
document.getElementById('barred').innerHTML = customer.barred
|
||||
? '<span class="badge bg-danger">Ja</span>'
|
||||
: '<span class="badge bg-success">Nej</span>';
|
||||
@ -3899,6 +3975,11 @@ function editCustomer() {
|
||||
document.getElementById('editAddress').value = customerData.address || '';
|
||||
document.getElementById('editPostalCode').value = customerData.postal_code || '';
|
||||
document.getElementById('editCity').value = customerData.city || '';
|
||||
document.getElementById('editStandardMarginPercent').value = (customerData.standard_margin_percent ?? customerDefaultMarginPercent);
|
||||
document.getElementById('editStandardHourlyRate').value = (customerData.standard_hourly_rate ?? customerDefaultHourlyRate);
|
||||
document.getElementById('editSpecialFreightPrice').value = customerData.special_freight_price ?? '';
|
||||
document.getElementById('editInvoiceFeeAmount').value = (customerData.invoice_fee_amount ?? customerDefaultInvoiceFee);
|
||||
document.getElementById('editSupplierServiceEnrolled').checked = !!customerData.supplier_service_enrolled;
|
||||
document.getElementById('editIsActive').checked = customerData.is_active !== false;
|
||||
|
||||
// Show modal
|
||||
@ -3907,6 +3988,11 @@ function editCustomer() {
|
||||
}
|
||||
|
||||
async function saveCustomerEdit() {
|
||||
const marginValue = document.getElementById('editStandardMarginPercent').value;
|
||||
const hourlyRateValue = document.getElementById('editStandardHourlyRate').value;
|
||||
const freightValue = document.getElementById('editSpecialFreightPrice').value;
|
||||
const invoiceFeeValue = document.getElementById('editInvoiceFeeAmount').value;
|
||||
|
||||
const updateData = {
|
||||
name: document.getElementById('editName').value,
|
||||
cvr_number: document.getElementById('editCvrNumber').value || null,
|
||||
@ -3920,6 +4006,11 @@ async function saveCustomerEdit() {
|
||||
address: document.getElementById('editAddress').value || null,
|
||||
postal_code: document.getElementById('editPostalCode').value || null,
|
||||
city: document.getElementById('editCity').value || null,
|
||||
standard_margin_percent: marginValue === '' ? customerDefaultMarginPercent : Number(marginValue),
|
||||
standard_hourly_rate: hourlyRateValue === '' ? customerDefaultHourlyRate : Number(hourlyRateValue),
|
||||
special_freight_price: freightValue === '' ? null : Number(freightValue),
|
||||
supplier_service_enrolled: document.getElementById('editSupplierServiceEnrolled').checked,
|
||||
invoice_fee_amount: invoiceFeeValue === '' ? customerDefaultInvoiceFee : Number(invoiceFeeValue),
|
||||
is_active: document.getElementById('editIsActive').checked
|
||||
};
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
@ -430,6 +432,203 @@ def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], us
|
||||
return int(order_id)
|
||||
|
||||
|
||||
def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
|
||||
customer = execute_query_single(
|
||||
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
|
||||
|
||||
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
|
||||
hub_customer_id = customer.get("hub_customer_id")
|
||||
|
||||
hub_customer = None
|
||||
if hub_customer_id:
|
||||
hub_customer = execute_query_single(
|
||||
"""
|
||||
SELECT
|
||||
standard_hourly_rate,
|
||||
standard_margin_percent,
|
||||
special_freight_price,
|
||||
supplier_service_enrolled,
|
||||
invoice_fee_amount
|
||||
FROM customers
|
||||
WHERE id = %s
|
||||
""",
|
||||
(hub_customer_id,),
|
||||
)
|
||||
|
||||
invoice_fee_amount = Decimal(
|
||||
str(
|
||||
(hub_customer or {}).get("invoice_fee_amount")
|
||||
if (hub_customer or {}).get("invoice_fee_amount") is not None
|
||||
else settings.CUSTOMER_DEFAULT_INVOICE_FEE
|
||||
)
|
||||
)
|
||||
special_freight_price = (hub_customer or {}).get("special_freight_price")
|
||||
special_freight_amount = Decimal(str(special_freight_price)) if special_freight_price is not None else Decimal("0")
|
||||
supplier_service_enrolled = bool((hub_customer or {}).get("supplier_service_enrolled"))
|
||||
standard_margin_percent = Decimal(
|
||||
str(
|
||||
(hub_customer or {}).get("standard_margin_percent")
|
||||
if (hub_customer or {}).get("standard_margin_percent") is not None
|
||||
else settings.CUSTOMER_DEFAULT_MARGIN_PERCENT
|
||||
)
|
||||
)
|
||||
base_hourly_rate = Decimal(
|
||||
str(
|
||||
(hub_customer or {}).get("standard_hourly_rate")
|
||||
if (hub_customer or {}).get("standard_hourly_rate") is not None
|
||||
else hourly_rate
|
||||
)
|
||||
)
|
||||
|
||||
grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
|
||||
"rows": [],
|
||||
"case_title": "Time entries",
|
||||
"case_id": None,
|
||||
"sag_id": None,
|
||||
})
|
||||
|
||||
for row in rows:
|
||||
group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
|
||||
grouped[group_key]["rows"].append(row)
|
||||
grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
|
||||
grouped[group_key]["case_id"] = row.get("case_id")
|
||||
grouped[group_key]["sag_id"] = row.get("sag_id")
|
||||
|
||||
line_payloads: List[Dict[str, Any]] = []
|
||||
|
||||
for _, group in grouped.items():
|
||||
qty = Decimal("0")
|
||||
ids: List[int] = []
|
||||
latest_date = None
|
||||
|
||||
for row in group["rows"]:
|
||||
qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0))
|
||||
ids.append(int(row["id"]))
|
||||
wd = row.get("worked_date")
|
||||
if wd and (latest_date is None or wd > latest_date):
|
||||
latest_date = wd
|
||||
|
||||
effective_margin_percent = standard_margin_percent if standard_margin_percent >= Decimal("0") else Decimal("0")
|
||||
unit_price = base_hourly_rate.quantize(Decimal("0.01"))
|
||||
amount = (qty * unit_price).quantize(Decimal("0.01"))
|
||||
|
||||
line_payloads.append(
|
||||
{
|
||||
"line_key": f"timequeue:{ids[0] if ids else 0}:{group.get('case_id') or 0}:{group.get('sag_id') or 0}",
|
||||
"source_type": "timequeue",
|
||||
"source_id": ids[0] if ids else None,
|
||||
"description": group["case_title"],
|
||||
"quantity": float(qty),
|
||||
"unit_price": float(unit_price),
|
||||
"discount_percentage": 0,
|
||||
"unit": "timer",
|
||||
"product_id": None,
|
||||
"selected": True,
|
||||
"amount": float(amount),
|
||||
"customer_id": int(hub_customer_id) if hub_customer_id else None,
|
||||
"customer_name": customer.get("name") or f"Kunde {customer_id}",
|
||||
"sag_id": group["sag_id"],
|
||||
"time_entry_ids": ids,
|
||||
"time_date": str(latest_date) if latest_date else None,
|
||||
"meta": {
|
||||
"base_hourly_rate": float(base_hourly_rate.quantize(Decimal("0.01"))),
|
||||
"standard_margin_percent": float(effective_margin_percent),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if special_freight_amount > 0:
|
||||
line_payloads.append(
|
||||
{
|
||||
"line_key": f"freight:{hub_customer_id or customer_id}",
|
||||
"source_type": "freight",
|
||||
"source_id": None,
|
||||
"description": "Særlig fragtpris",
|
||||
"quantity": 1.0,
|
||||
"unit_price": float(special_freight_amount.quantize(Decimal("0.01"))),
|
||||
"discount_percentage": 0,
|
||||
"unit": "stk",
|
||||
"product_id": None,
|
||||
"selected": True,
|
||||
"amount": float(special_freight_amount.quantize(Decimal("0.01"))),
|
||||
"customer_id": int(hub_customer_id) if hub_customer_id else None,
|
||||
"customer_name": customer.get("name") or f"Kunde {customer_id}",
|
||||
"sag_id": None,
|
||||
"time_entry_ids": [],
|
||||
"time_date": None,
|
||||
}
|
||||
)
|
||||
|
||||
# Fee line is included by default unless customer-specific value is 0.
|
||||
if invoice_fee_amount > 0 and not supplier_service_enrolled:
|
||||
line_payloads.append(
|
||||
{
|
||||
"line_key": f"invoice_fee:{hub_customer_id or customer_id}",
|
||||
"source_type": "invoice_fee",
|
||||
"source_id": None,
|
||||
"description": "Faktureringsgebyr",
|
||||
"quantity": 1.0,
|
||||
"unit_price": float(invoice_fee_amount.quantize(Decimal("0.01"))),
|
||||
"discount_percentage": 0,
|
||||
"unit": "stk",
|
||||
"product_id": None,
|
||||
"selected": True,
|
||||
"amount": float(invoice_fee_amount.quantize(Decimal("0.01"))),
|
||||
"customer_id": int(hub_customer_id) if hub_customer_id else None,
|
||||
"customer_name": customer.get("name") or f"Kunde {customer_id}",
|
||||
"sag_id": None,
|
||||
"time_entry_ids": [],
|
||||
"time_date": None,
|
||||
"meta": {
|
||||
"standard_margin_percent": float(standard_margin_percent),
|
||||
"supplier_service_enrolled": supplier_service_enrolled,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if not line_payloads:
|
||||
raise HTTPException(status_code=400, detail="No order lines generated from selected entries")
|
||||
|
||||
draft_title = f"Timefaktura {customer.get('name') or f'Kunde {customer_id}'} - {date.today().isoformat()}"
|
||||
invoice_aggregate_key = f"timequeue-customer-{hub_customer_id or customer_id}"
|
||||
|
||||
draft = execute_query_single(
|
||||
"""
|
||||
INSERT INTO ordre_drafts (
|
||||
title,
|
||||
customer_id,
|
||||
lines_json,
|
||||
notes,
|
||||
layout_number,
|
||||
created_by_user_id,
|
||||
sync_status,
|
||||
export_status_json,
|
||||
invoice_aggregate_key,
|
||||
updated_at
|
||||
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, %s, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
draft_title,
|
||||
int(hub_customer_id) if hub_customer_id else None,
|
||||
json.dumps(line_payloads, ensure_ascii=False),
|
||||
"Genereret fra Economy Time Queue",
|
||||
1,
|
||||
user_id,
|
||||
json.dumps({}, ensure_ascii=False),
|
||||
invoice_aggregate_key,
|
||||
),
|
||||
)
|
||||
if not draft:
|
||||
raise HTTPException(status_code=500, detail="Failed creating ordre draft")
|
||||
|
||||
return int(draft["id"])
|
||||
|
||||
|
||||
def _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optional[int]) -> Optional[int]:
|
||||
"""Resolve any incoming customer reference to a valid tmodule_customers.id.
|
||||
|
||||
@ -556,12 +755,12 @@ async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
|
||||
|
||||
rows_by_customer[int(resolved_customer_id)].append(row)
|
||||
|
||||
created_orders = []
|
||||
created_drafts = []
|
||||
failed_customers: List[Dict[str, Any]] = []
|
||||
for cust_id, cust_rows in rows_by_customer.items():
|
||||
try:
|
||||
order_id = _create_order_from_selected(cust_id, cust_rows, user_id)
|
||||
created_orders.append({"customer_id": cust_id, "order_id": order_id})
|
||||
draft_id = _create_ordre_draft_from_selected(cust_id, cust_rows, user_id)
|
||||
created_drafts.append({"customer_id": cust_id, "draft_id": draft_id})
|
||||
except HTTPException as ex:
|
||||
failed_customers.append(
|
||||
{
|
||||
@ -571,7 +770,7 @@ async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
|
||||
}
|
||||
)
|
||||
|
||||
if not created_orders:
|
||||
if not created_drafts:
|
||||
if skipped_missing_customer:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@ -586,20 +785,21 @@ async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
|
||||
|
||||
# Time queue must never push directly to e-conomic.
|
||||
# Orders are created locally and can be transferred manually from Orders page.
|
||||
order_ids = [o["order_id"] for o in created_orders]
|
||||
draft_ids = [o["draft_id"] for o in created_drafts]
|
||||
orders_url = "/ordre"
|
||||
if len(order_ids) == 1:
|
||||
orders_url = f"/ordre/{order_ids[0]}"
|
||||
if len(draft_ids) == 1:
|
||||
orders_url = f"/ordre/{draft_ids[0]}"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"selected": len(ids),
|
||||
"order_candidates": len(selected_order_ids),
|
||||
"created_orders": created_orders,
|
||||
"created_drafts": created_drafts,
|
||||
"created_orders": [{"customer_id": d["customer_id"], "order_id": d["draft_id"]} for d in created_drafts],
|
||||
"skipped_missing_customer": skipped_missing_customer,
|
||||
"failed_customers": failed_customers,
|
||||
"orders_url": orders_url,
|
||||
"message": "Lokale ordrer oprettet. Overfoer til e-conomic fra Ordre-siden.",
|
||||
"message": "Ordrekladder oprettet i /ordre. Klar til konsolidering og overfoersel.",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@ -455,18 +455,19 @@
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
const orders = (result.created_orders || []).map((x) => {
|
||||
return `customer ${x.customer_id}, order ${x.order_id}`;
|
||||
const drafts = (result.created_drafts || result.created_orders || []).map((x) => {
|
||||
const draftId = x.draft_id || x.order_id;
|
||||
return `customer ${x.customer_id}, draft ${draftId}`;
|
||||
}).join('\n');
|
||||
const skipped = (result.skipped_missing_customer || []);
|
||||
const failedCustomers = (result.failed_customers || []);
|
||||
const orderMessage = orders || 'Ingen ordrer oprettet';
|
||||
const orderMessage = drafts || 'Ingen ordrekladder oprettet';
|
||||
const nextStep = result.orders_url ? `\n\nAabn ordre: ${result.orders_url}` : '';
|
||||
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
|
||||
const failedMsg = failedCustomers.length
|
||||
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`
|
||||
: '';
|
||||
alert(`Lokale ordrer oprettet:\n${orderMessage}${skippedMsg}${failedMsg}${nextStep}`);
|
||||
alert(`Ordrekladder oprettet i /ordre:\n${orderMessage}${skippedMsg}${failedMsg}${nextStep}`);
|
||||
await loadCustomers();
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
|
||||
@ -35,6 +35,11 @@ class CustomerUpdate(BaseModel):
|
||||
mobile_phone: Optional[str] = None
|
||||
invoice_email: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
standard_margin_percent: Optional[float] = None
|
||||
standard_hourly_rate: Optional[float] = None
|
||||
special_freight_price: Optional[float] = None
|
||||
supplier_service_enrolled: Optional[bool] = None
|
||||
invoice_fee_amount: Optional[float] = None
|
||||
|
||||
|
||||
class Customer(CustomerBase):
|
||||
|
||||
@ -1200,12 +1200,12 @@ async def create_contact(location_id: int, data: ContactCreate):
|
||||
|
||||
location_name = location_check[0]['name']
|
||||
|
||||
contact_name = (data.contact_name or "").strip()
|
||||
contact_email = _none_if_empty(data.contact_email)
|
||||
contact_phone = _none_if_empty(data.contact_phone)
|
||||
contact_role = _none_if_empty(data.role)
|
||||
if not data.existing_contact_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Du skal vælge en eksisterende kontakt"
|
||||
)
|
||||
|
||||
if data.existing_contact_id:
|
||||
existing_contact_query = """
|
||||
SELECT id, first_name, last_name, email, phone, mobile, title
|
||||
FROM contacts
|
||||
@ -1220,22 +1220,69 @@ async def create_contact(location_id: int, data: ContactCreate):
|
||||
)
|
||||
|
||||
existing_contact = existing_contact_result[0]
|
||||
existing_full_name = (
|
||||
contact_name = (
|
||||
f"{(existing_contact.get('first_name') or '').strip()} "
|
||||
f"{(existing_contact.get('last_name') or '').strip()}"
|
||||
).strip()
|
||||
|
||||
if not contact_name:
|
||||
contact_name = existing_full_name
|
||||
if not contact_email:
|
||||
contact_email = _none_if_empty(existing_contact.get('email'))
|
||||
if not contact_phone:
|
||||
contact_phone = _none_if_empty(existing_contact.get('mobile')) or _none_if_empty(existing_contact.get('phone'))
|
||||
if not contact_role:
|
||||
contact_role = _none_if_empty(existing_contact.get('title'))
|
||||
contact_role = _none_if_empty(data.role) or _none_if_empty(existing_contact.get('title'))
|
||||
|
||||
if not contact_name:
|
||||
raise HTTPException(status_code=400, detail="contact_name is required")
|
||||
raise HTTPException(status_code=400, detail="Valgt kontakt mangler navn")
|
||||
|
||||
existing_relation_result = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, related_contact_id, contact_name, contact_email, contact_phone,
|
||||
role, is_primary, created_at
|
||||
FROM locations_contacts
|
||||
WHERE location_id = %s
|
||||
AND related_contact_id = %s
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
""",
|
||||
(location_id, data.existing_contact_id),
|
||||
)
|
||||
|
||||
if existing_relation_result:
|
||||
existing_relation = existing_relation_result[0]
|
||||
|
||||
if data.is_primary and not existing_relation.get("is_primary"):
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE locations_contacts
|
||||
SET is_primary = false
|
||||
WHERE location_id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
(location_id,),
|
||||
)
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE locations_contacts
|
||||
SET is_primary = true
|
||||
WHERE id = %s
|
||||
RETURNING id, location_id, related_contact_id, contact_name, contact_email,
|
||||
contact_phone, role, is_primary, created_at
|
||||
""",
|
||||
(existing_relation["id"],),
|
||||
)
|
||||
existing_relation_result = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, related_contact_id, contact_name, contact_email, contact_phone,
|
||||
role, is_primary, created_at
|
||||
FROM locations_contacts
|
||||
WHERE id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(existing_relation["id"],),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"ℹ️ Existing contact relation reused for location %s and contact %s",
|
||||
location_id,
|
||||
data.existing_contact_id,
|
||||
)
|
||||
return Contact(**existing_relation_result[0])
|
||||
|
||||
# If is_primary is true, unset primary flag on other contacts
|
||||
if data.is_primary:
|
||||
@ -1246,13 +1293,43 @@ async def create_contact(location_id: int, data: ContactCreate):
|
||||
"""
|
||||
execute_query(unset_primary_query, (location_id,))
|
||||
|
||||
has_related_contact_id_column = bool(execute_query(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'locations_contacts'
|
||||
AND column_name = 'related_contact_id'
|
||||
LIMIT 1
|
||||
"""
|
||||
))
|
||||
|
||||
# INSERT new contact
|
||||
if has_related_contact_id_column:
|
||||
insert_query = """
|
||||
INSERT INTO locations_contacts (
|
||||
location_id, related_contact_id, contact_name, contact_email, contact_phone,
|
||||
role, is_primary, created_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
params = (
|
||||
location_id,
|
||||
data.existing_contact_id,
|
||||
contact_name,
|
||||
contact_email,
|
||||
contact_phone,
|
||||
contact_role,
|
||||
data.is_primary,
|
||||
)
|
||||
else:
|
||||
insert_query = """
|
||||
INSERT INTO locations_contacts (
|
||||
location_id, contact_name, contact_email, contact_phone,
|
||||
role, is_primary, created_at, updated_at
|
||||
role, is_primary, created_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||
VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
@ -1262,7 +1339,7 @@ async def create_contact(location_id: int, data: ContactCreate):
|
||||
contact_email,
|
||||
contact_phone,
|
||||
contact_role,
|
||||
data.is_primary
|
||||
data.is_primary,
|
||||
)
|
||||
|
||||
result = execute_query(insert_query, params)
|
||||
|
||||
@ -412,8 +412,85 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
||||
(id,)
|
||||
)
|
||||
|
||||
contacts = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, related_contact_id, contact_name, contact_email, contact_phone,
|
||||
role, is_primary, created_at
|
||||
FROM locations_contacts
|
||||
WHERE location_id = %s AND deleted_at IS NULL
|
||||
ORDER BY is_primary DESC, contact_name ASC
|
||||
""",
|
||||
(id,)
|
||||
)
|
||||
|
||||
operating_hours = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, day_of_week,
|
||||
CASE day_of_week
|
||||
WHEN 0 THEN 'Mandag'
|
||||
WHEN 1 THEN 'Tirsdag'
|
||||
WHEN 2 THEN 'Onsdag'
|
||||
WHEN 3 THEN 'Torsdag'
|
||||
WHEN 4 THEN 'Fredag'
|
||||
WHEN 5 THEN 'Lørdag'
|
||||
WHEN 6 THEN 'Søndag'
|
||||
END AS day_name,
|
||||
open_time, close_time, is_open, notes
|
||||
FROM locations_hours
|
||||
WHERE location_id = %s
|
||||
ORDER BY day_of_week ASC
|
||||
""",
|
||||
(id,)
|
||||
)
|
||||
|
||||
services = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, service_name, is_available, created_at
|
||||
FROM locations_services
|
||||
WHERE location_id = %s AND deleted_at IS NULL
|
||||
ORDER BY service_name ASC
|
||||
""",
|
||||
(id,)
|
||||
)
|
||||
|
||||
capacity = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, capacity_type, total_capacity, used_capacity, last_updated
|
||||
FROM locations_capacity
|
||||
WHERE location_id = %s
|
||||
ORDER BY capacity_type ASC
|
||||
""",
|
||||
(id,)
|
||||
)
|
||||
|
||||
hardware = execute_query(
|
||||
"""
|
||||
SELECT id, asset_type, brand, model, serial_number, status
|
||||
FROM hardware_assets
|
||||
WHERE current_location_id = %s AND deleted_at IS NULL
|
||||
ORDER BY brand ASC, model ASC, serial_number ASC
|
||||
""",
|
||||
(id,)
|
||||
)
|
||||
|
||||
audit_log = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, event_type, user_id, changes, created_at
|
||||
FROM locations_audit_log
|
||||
WHERE location_id = %s
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(id,)
|
||||
)
|
||||
|
||||
location["hierarchy"] = hierarchy
|
||||
location["children"] = children
|
||||
location["contacts"] = contacts or []
|
||||
location["operating_hours"] = operating_hours or []
|
||||
location["services"] = services or []
|
||||
location["capacity"] = capacity or []
|
||||
location["hardware"] = hardware or []
|
||||
location["audit_log"] = audit_log or []
|
||||
|
||||
# Query customers
|
||||
customers = execute_query("""
|
||||
|
||||
@ -117,14 +117,19 @@ class ContactBase(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ContactCreate(ContactBase):
|
||||
"""Request model for creating contact"""
|
||||
is_primary: bool = Field(False, description="Set as primary contact for location")
|
||||
existing_contact_id: Optional[int] = Field(
|
||||
None,
|
||||
class ContactCreate(BaseModel):
|
||||
"""Request model for linking an existing global contact to a location"""
|
||||
existing_contact_id: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Optional ID of an existing global contact to copy data from"
|
||||
description="ID of existing global contact"
|
||||
)
|
||||
role: Optional[str] = Field(
|
||||
None,
|
||||
max_length=100,
|
||||
description="Optional location-specific role override"
|
||||
)
|
||||
is_primary: bool = Field(False, description="Set as primary contact for location")
|
||||
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
@ -140,6 +145,7 @@ class Contact(ContactBase):
|
||||
"""Full contact response model"""
|
||||
id: int
|
||||
location_id: int
|
||||
related_contact_id: Optional[int] = None
|
||||
is_primary: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@ -2,8 +2,210 @@
|
||||
|
||||
{% block title %}{{ location.name }} - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.locations-detail-page {
|
||||
--loc-accent: var(--accent, #0f4c75);
|
||||
}
|
||||
|
||||
.locations-detail-page .case-hero {
|
||||
background: var(--bg-card, #fff);
|
||||
border-radius: 16px;
|
||||
overflow: visible;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0,0,0,0.06),
|
||||
0 4px 6px -1px rgba(0,0,0,0.05),
|
||||
0 16px 32px -8px rgba(15,76,117,0.10);
|
||||
}
|
||||
|
||||
.locations-detail-page .case-hero-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: linear-gradient(135deg, rgba(15,76,117,0.05) 0%, rgba(15,76,117,0.01) 100%);
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
border-radius: 16px 16px 0 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-id-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.4px;
|
||||
color: var(--loc-accent);
|
||||
background: color-mix(in srgb, var(--loc-accent) 10%, transparent);
|
||||
border: 1.5px solid color-mix(in srgb, var(--loc-accent) 30%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 0.2em 0.65em;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-type-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.73rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--loc-accent);
|
||||
background: color-mix(in srgb, var(--loc-accent) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--loc-accent) 25%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.32em 0.8em;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 0.73rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 999px;
|
||||
padding: 0.3em 0.85em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-status-chip.open {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-status-chip.closed {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-hero-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem 1rem 0.95rem;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-meta-cell {
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--loc-accent) 4%, var(--bg-card, #fff));
|
||||
padding: 0.58rem 0.7rem;
|
||||
}
|
||||
|
||||
.locations-detail-page .hero-meta-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.7;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.locations-detail-page .hero-meta-value {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.locations-detail-page #locationTabs {
|
||||
border-bottom: none;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.locations-detail-page #locationTabs .nav-link {
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 999px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-card, #fff);
|
||||
font-weight: 600;
|
||||
padding: 0.44rem 0.82rem;
|
||||
transition: all 0.16s ease;
|
||||
}
|
||||
|
||||
.locations-detail-page #locationTabs .nav-link:hover,
|
||||
.locations-detail-page #locationTabs .nav-link:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--loc-accent) 45%, transparent);
|
||||
color: var(--loc-accent);
|
||||
}
|
||||
|
||||
.locations-detail-page #locationTabs .nav-link.active {
|
||||
background: color-mix(in srgb, var(--loc-accent) 12%, var(--bg-card, #fff));
|
||||
border-color: color-mix(in srgb, var(--loc-accent) 40%, transparent);
|
||||
color: var(--loc-accent);
|
||||
}
|
||||
|
||||
.locations-detail-page .location-tab-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border-radius: 999px;
|
||||
padding: 0 0.34rem;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
background: color-mix(in srgb, var(--loc-accent) 14%, transparent);
|
||||
color: var(--loc-accent);
|
||||
}
|
||||
|
||||
.locations-detail-page .card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.08) !important;
|
||||
box-shadow: 0 6px 18px rgba(15, 76, 117, 0.08);
|
||||
}
|
||||
|
||||
.locations-detail-page .list-group-item {
|
||||
border-radius: 0.8rem !important;
|
||||
margin-bottom: 0.45rem;
|
||||
border: 1px solid rgba(15, 76, 117, 0.1);
|
||||
}
|
||||
|
||||
.locations-detail-page .timeline-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.locations-detail-page .timeline-item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 20px;
|
||||
bottom: -14px;
|
||||
width: 1px;
|
||||
background: rgba(15, 76, 117, 0.2);
|
||||
}
|
||||
|
||||
.locations-detail-page .timeline-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.locations-detail-page {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-hero-meta {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<div class="container-fluid px-4 py-4 locations-detail-page">
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
@ -16,24 +218,10 @@
|
||||
<!-- Header Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h1 class="h2 fw-700 mb-2">{{ location.name }}</h1>
|
||||
{% if location.hierarchy %}
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="breadcrumb mb-0">
|
||||
{% for node in location.hierarchy %}
|
||||
<li class="breadcrumb-item">
|
||||
<a href="/app/locations/{{ node.id }}" class="text-decoration-none">
|
||||
{{ node.name }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ location.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<div class="case-hero">
|
||||
<div class="case-hero-identity">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<span class="case-id-chip">Lokation #{{ location.id }}</span>
|
||||
{% set type_label = {
|
||||
'kompleks': 'Kompleks',
|
||||
'bygning': 'Bygning',
|
||||
@ -56,23 +244,18 @@
|
||||
'vehicle': '#8e44ad'
|
||||
}.get(location.location_type, '#6c757d') %}
|
||||
|
||||
<span class="badge" style="background-color: {{ type_color }}; color: white;">
|
||||
<span class="case-type-chip" style="--tcolor: {{ type_color }};">
|
||||
{{ type_label }}
|
||||
</span>
|
||||
{% if location.parent_location_id and location.parent_location_name %}
|
||||
<span class="text-muted small">
|
||||
<i class="bi bi-diagram-3 me-1"></i>
|
||||
<a href="/app/locations/{{ location.parent_location_id }}" class="text-decoration-none">
|
||||
{{ location.parent_location_name }}
|
||||
</a>
|
||||
{% if location.is_active %}
|
||||
<span class="case-status-chip open">
|
||||
<span class="case-status-dot"></span>Aktiv
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="case-status-chip closed">
|
||||
<span class="case-status-dot"></span>Inaktiv
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if location.is_active %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inaktiv</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/app/locations/{{ location.id }}/edit" class="btn btn-primary btn-sm">
|
||||
@ -86,11 +269,37 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="case-hero-meta">
|
||||
<div class="case-meta-cell">
|
||||
<div class="hero-meta-label">Navn</div>
|
||||
<div class="hero-meta-value">{{ location.name }}</div>
|
||||
</div>
|
||||
<div class="case-meta-cell">
|
||||
<div class="hero-meta-label">Overordnet</div>
|
||||
{% if location.parent_location_id and location.parent_location_name %}
|
||||
<a href="/app/locations/{{ location.parent_location_id }}" class="hero-meta-value text-decoration-none">
|
||||
{{ location.parent_location_name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="hero-meta-value text-muted">Ingen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="case-meta-cell">
|
||||
<div class="hero-meta-label">Kontakter</div>
|
||||
<div class="hero-meta-value">{{ location.contacts|length if location.contacts else 0 }}</div>
|
||||
</div>
|
||||
<div class="case-meta-cell">
|
||||
<div class="hero-meta-label">Tjenester</div>
|
||||
<div class="hero-meta-value">{{ location.services|length if location.services else 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
<ul class="nav nav-tabs mb-4" role="tablist">
|
||||
<ul class="nav nav-tabs mb-4" id="locationTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="infoTab" data-bs-toggle="tab" data-bs-target="#infoContent" type="button" role="tab" aria-controls="infoContent" aria-selected="true">
|
||||
<i class="bi bi-info-circle me-2"></i>Oplysninger
|
||||
@ -99,6 +308,7 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="contactsTab" data-bs-toggle="tab" data-bs-target="#contactsContent" type="button" role="tab" aria-controls="contactsContent" aria-selected="false">
|
||||
<i class="bi bi-people me-2"></i>Kontakter
|
||||
<span class="location-tab-count-badge ms-1">{{ location.contacts|length if location.contacts else 0 }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
@ -109,11 +319,13 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="servicesTab" data-bs-toggle="tab" data-bs-target="#servicesContent" type="button" role="tab" aria-controls="servicesContent" aria-selected="false">
|
||||
<i class="bi bi-tools me-2"></i>Tjenester
|
||||
<span class="location-tab-count-badge ms-1">{{ location.services|length if location.services else 0 }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="capacityTab" data-bs-toggle="tab" data-bs-target="#capacityContent" type="button" role="tab" aria-controls="capacityContent" aria-selected="false">
|
||||
<i class="bi bi-graph-up me-2"></i>Kapacitet
|
||||
<span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
@ -124,6 +336,7 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="hardwareTab" data-bs-toggle="tab" data-bs-target="#hardwareContent" type="button" role="tab" aria-controls="hardwareContent" aria-selected="false">
|
||||
<i class="bi bi-hdd-stack me-2"></i>Hardware på lokation
|
||||
<span class="location-tab-count-badge ms-1">{{ location.hardware|length if location.hardware else 0 }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
@ -301,7 +514,13 @@
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="fw-600 mb-1">{{ contact.contact_name }}</h6>
|
||||
<h6 class="fw-600 mb-1">
|
||||
{% if contact.related_contact_id %}
|
||||
<a href="/contacts/{{ contact.related_contact_id }}" class="text-decoration-none">{{ contact.contact_name }}</a>
|
||||
{% else %}
|
||||
{{ contact.contact_name }}
|
||||
{% endif %}
|
||||
</h6>
|
||||
<p class="small text-muted mb-2">
|
||||
{% if contact.role %}{{ contact.role }}{% endif %}
|
||||
{% if contact.is_primary %}<span class="badge bg-info ms-2">Primær</span>{% endif %}
|
||||
@ -663,16 +882,21 @@
|
||||
<form id="addContactForm">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="contactName" class="form-label">Navn *</label>
|
||||
<input type="text" class="form-control" id="contactName" required>
|
||||
<label for="existingContactSearch" class="form-label">Søg eksisterende kontakt</label>
|
||||
<input type="text" class="form-control" id="existingContactSearch" placeholder="Skriv navn, email eller telefon...">
|
||||
<div class="form-text">Vælg en eksisterende kontakt for at udfylde felterne automatisk.</div>
|
||||
<div id="existingContactResults" class="list-group mt-2 d-none" style="max-height: 220px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="contactEmail" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="contactEmail">
|
||||
<div id="selectedExistingContact" class="alert alert-info py-2 px-3 d-none" role="alert">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span id="selectedExistingContactText" class="small mb-0"></span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0" id="clearExistingContactBtn">Fjern</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="existingContactId" value="">
|
||||
<div class="mb-3">
|
||||
<label for="contactPhone" class="form-label">Telefon</label>
|
||||
<input type="tel" class="form-control" id="contactPhone">
|
||||
<label class="form-label">Kontaktoplysninger</label>
|
||||
<div class="form-control-plaintext small text-muted" id="selectedContactMeta">Vælg en kontakt for at se email og telefon.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="contactRole" class="form-label">Rolle</label>
|
||||
@ -781,6 +1005,129 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||
const locationId = '{{ location.id }}';
|
||||
const existingContactSearchInput = document.getElementById('existingContactSearch');
|
||||
const existingContactResultsContainer = document.getElementById('existingContactResults');
|
||||
const existingContactIdInput = document.getElementById('existingContactId');
|
||||
const selectedExistingContactAlert = document.getElementById('selectedExistingContact');
|
||||
const selectedExistingContactText = document.getElementById('selectedExistingContactText');
|
||||
const selectedContactMeta = document.getElementById('selectedContactMeta');
|
||||
const clearExistingContactBtn = document.getElementById('clearExistingContactBtn');
|
||||
const contactRoleInput = document.getElementById('contactRole');
|
||||
const addContactModalElement = document.getElementById('addContactModal');
|
||||
const addContactForm = document.getElementById('addContactForm');
|
||||
const addContactSubmitBtn = addContactForm?.querySelector('button[type="submit"]');
|
||||
let existingContactResults = [];
|
||||
let contactSearchDebounceTimer = null;
|
||||
let isSavingContact = false;
|
||||
|
||||
function clearExistingContactSelection() {
|
||||
existingContactIdInput.value = '';
|
||||
selectedExistingContactText.textContent = '';
|
||||
selectedExistingContactAlert.classList.add('d-none');
|
||||
selectedContactMeta.textContent = 'Vælg en kontakt for at se email og telefon.';
|
||||
}
|
||||
|
||||
function hideExistingContactResults() {
|
||||
existingContactResults = [];
|
||||
existingContactResultsContainer.innerHTML = '';
|
||||
existingContactResultsContainer.classList.add('d-none');
|
||||
}
|
||||
|
||||
function buildFullName(contact) {
|
||||
const firstName = (contact.first_name || '').trim();
|
||||
const lastName = (contact.last_name || '').trim();
|
||||
return `${firstName} ${lastName}`.trim();
|
||||
}
|
||||
|
||||
function selectExistingContact(contact) {
|
||||
const fullName = buildFullName(contact);
|
||||
const email = contact.email || '';
|
||||
const phone = contact.mobile || contact.phone || '';
|
||||
|
||||
existingContactIdInput.value = String(contact.id || '');
|
||||
selectedExistingContactText.textContent = `Valgt: ${fullName || 'Kontakt'} (ID: ${contact.id})`;
|
||||
selectedExistingContactAlert.classList.remove('d-none');
|
||||
selectedContactMeta.textContent = `${email || 'Ingen email'} • ${phone || 'Ingen telefon'}`;
|
||||
|
||||
hideExistingContactResults();
|
||||
existingContactSearchInput.value = fullName;
|
||||
}
|
||||
|
||||
function renderExistingContactResults(results) {
|
||||
if (!results || !results.length) {
|
||||
hideExistingContactResults();
|
||||
return;
|
||||
}
|
||||
|
||||
existingContactResults = results;
|
||||
existingContactResultsContainer.innerHTML = results.map((contact, index) => {
|
||||
const fullName = buildFullName(contact) || `Kontakt #${contact.id}`;
|
||||
const secondaryInfo = [contact.email, contact.mobile || contact.phone, contact.user_company]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
return `
|
||||
<button type="button" class="list-group-item list-group-item-action existing-contact-result" data-index="${index}">
|
||||
<div class="fw-500">${fullName}</div>
|
||||
<div class="small text-muted">${secondaryInfo || 'Ingen ekstra info'}</div>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
existingContactResultsContainer.classList.remove('d-none');
|
||||
}
|
||||
|
||||
async function searchExistingContacts(term) {
|
||||
if (!term || term.trim().length < 2) {
|
||||
hideExistingContactResults();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(term.trim())}`);
|
||||
if (!response.ok) {
|
||||
hideExistingContactResults();
|
||||
return;
|
||||
}
|
||||
|
||||
const contacts = await response.json();
|
||||
renderExistingContactResults(contacts || []);
|
||||
} catch (error) {
|
||||
console.error('Error searching contacts:', error);
|
||||
hideExistingContactResults();
|
||||
}
|
||||
}
|
||||
|
||||
existingContactSearchInput.addEventListener('input', function(e) {
|
||||
const term = e.target.value;
|
||||
if (contactSearchDebounceTimer) {
|
||||
clearTimeout(contactSearchDebounceTimer);
|
||||
}
|
||||
contactSearchDebounceTimer = setTimeout(() => {
|
||||
searchExistingContacts(term);
|
||||
}, 220);
|
||||
});
|
||||
|
||||
existingContactResultsContainer.addEventListener('click', function(e) {
|
||||
const button = e.target.closest('.existing-contact-result');
|
||||
if (!button) return;
|
||||
|
||||
const index = Number(button.dataset.index);
|
||||
const selectedContact = existingContactResults[index];
|
||||
if (selectedContact) {
|
||||
selectExistingContact(selectedContact);
|
||||
}
|
||||
});
|
||||
|
||||
clearExistingContactBtn.addEventListener('click', function() {
|
||||
clearExistingContactSelection();
|
||||
existingContactSearchInput.value = '';
|
||||
hideExistingContactResults();
|
||||
});
|
||||
|
||||
addContactModalElement.addEventListener('hidden.bs.modal', function() {
|
||||
hideExistingContactResults();
|
||||
clearExistingContactSelection();
|
||||
existingContactSearchInput.value = '';
|
||||
});
|
||||
|
||||
// Delete location
|
||||
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
|
||||
@ -803,14 +1150,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
|
||||
// Add contact form
|
||||
document.getElementById('addContactForm').addEventListener('submit', function(e) {
|
||||
addContactForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
if (isSavingContact) {
|
||||
return;
|
||||
}
|
||||
if (!existingContactIdInput.value) {
|
||||
alert('Vælg en eksisterende kontakt fra søgningen før du gemmer.');
|
||||
return;
|
||||
}
|
||||
|
||||
isSavingContact = true;
|
||||
if (addContactSubmitBtn) {
|
||||
addContactSubmitBtn.disabled = true;
|
||||
addContactSubmitBtn.textContent = 'Gemmer...';
|
||||
}
|
||||
|
||||
const contactData = {
|
||||
location_id: locationId,
|
||||
contact_name: document.getElementById('contactName').value,
|
||||
contact_email: document.getElementById('contactEmail').value,
|
||||
contact_phone: document.getElementById('contactPhone').value,
|
||||
role: document.getElementById('contactRole').value,
|
||||
role: contactRoleInput.value,
|
||||
existing_contact_id: existingContactIdInput.value ? parseInt(existingContactIdInput.value, 10) : null,
|
||||
is_primary: document.getElementById('isPrimaryContact').checked
|
||||
};
|
||||
fetch(`/api/v1/locations/${locationId}/contacts`, {
|
||||
@ -818,13 +1177,35 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(contactData)
|
||||
})
|
||||
.then(response => {
|
||||
.then(async response => {
|
||||
if (response.ok) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('addContactModal')).hide();
|
||||
setTimeout(() => location.reload(), 300);
|
||||
return;
|
||||
}
|
||||
|
||||
let detail = 'Fejl ved gem af kontaktlink';
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload && payload.detail) {
|
||||
detail = String(payload.detail);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore parse errors and keep default message
|
||||
}
|
||||
alert(detail);
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Netværksfejl ved gem af kontaktlink');
|
||||
})
|
||||
.finally(() => {
|
||||
isSavingContact = false;
|
||||
if (addContactSubmitBtn) {
|
||||
addContactSubmitBtn.disabled = false;
|
||||
addContactSubmitBtn.textContent = 'Tilføj';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add service form
|
||||
|
||||
@ -2,8 +2,91 @@
|
||||
|
||||
{% block title %}Lokaliteter - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.locations-list-page {
|
||||
--loc-accent: #0f4c75;
|
||||
--loc-accent-soft: rgba(15, 76, 117, 0.08);
|
||||
--loc-border: rgba(15, 76, 117, 0.16);
|
||||
}
|
||||
|
||||
.locations-list-page .locations-hero {
|
||||
border: 1px solid var(--loc-border);
|
||||
background:
|
||||
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.18), transparent 45%),
|
||||
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.16), transparent 42%),
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(247, 251, 255, 0.9));
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 76, 117, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .locations-list-page .locations-hero {
|
||||
background:
|
||||
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.2), transparent 45%),
|
||||
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.18), transparent 42%),
|
||||
linear-gradient(145deg, rgba(17, 34, 51, 0.9), rgba(11, 25, 38, 0.92));
|
||||
}
|
||||
|
||||
.locations-list-page .stat-tile {
|
||||
background: var(--loc-accent-soft);
|
||||
border: 1px solid var(--loc-border);
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
}
|
||||
|
||||
.locations-list-page .stat-tile .stat-value {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--loc-accent);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .locations-list-page .stat-tile .stat-value {
|
||||
color: #8fd0ff;
|
||||
}
|
||||
|
||||
.locations-list-page .table thead th {
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.locations-list-page .location-row {
|
||||
transition: background-color 0.18s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.locations-list-page .location-row:hover {
|
||||
background-color: rgba(52, 152, 219, 0.08);
|
||||
}
|
||||
|
||||
.locations-list-page .toggle-row {
|
||||
color: var(--loc-accent);
|
||||
}
|
||||
|
||||
.locations-list-page .shortcut-hint {
|
||||
border: 1px dashed var(--loc-border);
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.locations-list-page {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.locations-list-page .stat-tile {
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<div class="container-fluid px-4 py-4 locations-list-page">
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
@ -15,8 +98,41 @@
|
||||
<!-- Header Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="h2 fw-700 mb-2">Lokaliteter</h1>
|
||||
<p class="text-muted small">Oversigt over alle lokationer og faciliteter</p>
|
||||
<div class="locations-hero p-3 p-lg-4">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1>
|
||||
<p class="text-muted small mb-0">Oversigt over alle lokationer og faciliteter</p>
|
||||
</div>
|
||||
<span class="shortcut-hint">Tip: Tryk / for at fokusere søgning</span>
|
||||
</div>
|
||||
<div class="row g-2 mt-2">
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Total</div>
|
||||
<div class="stat-value" id="statTotal">{{ total or 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Aktive</div>
|
||||
<div class="stat-value" id="statActive">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Inaktive</div>
|
||||
<div class="stat-value" id="statInactive">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Synlige nu</div>
|
||||
<div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -294,12 +410,32 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('locationSearch');
|
||||
const visibleCount = document.getElementById('visibleCount');
|
||||
const statTotal = document.getElementById('statTotal');
|
||||
const statActive = document.getElementById('statActive');
|
||||
const statInactive = document.getElementById('statInactive');
|
||||
const statVisible = document.getElementById('statVisible');
|
||||
const rows = Array.from(document.querySelectorAll('.location-row'));
|
||||
|
||||
const rowById = new Map();
|
||||
const parentById = new Map();
|
||||
const childrenById = new Map();
|
||||
|
||||
function updateSummaryStats() {
|
||||
const total = rows.length;
|
||||
const active = rows.filter(row => {
|
||||
const statusText = row.querySelector('td:nth-child(5)')?.innerText?.toLowerCase() || '';
|
||||
return statusText.includes('aktiv') && !statusText.includes('inaktiv');
|
||||
}).length;
|
||||
const inactive = Math.max(total - active, 0);
|
||||
const visible = rows.filter(row => !row.classList.contains('d-none')).length;
|
||||
|
||||
if (statTotal) statTotal.textContent = String(total);
|
||||
if (statActive) statActive.textContent = String(active);
|
||||
if (statInactive) statInactive.textContent = String(inactive);
|
||||
if (statVisible) statVisible.textContent = String(visible);
|
||||
if (visibleCount) visibleCount.textContent = String(visible);
|
||||
}
|
||||
|
||||
rows.forEach(row => {
|
||||
const id = row.getAttribute('data-location-id');
|
||||
const parentId = row.getAttribute('data-parent-id') || null;
|
||||
@ -374,6 +510,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
collapseAll();
|
||||
updateSummaryStats();
|
||||
|
||||
function toggleNode(targetId) {
|
||||
const row = rowById.get(targetId);
|
||||
@ -442,6 +579,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (visibleCount) {
|
||||
visibleCount.textContent = String(visible);
|
||||
}
|
||||
if (statVisible) {
|
||||
statVisible.textContent = String(visible);
|
||||
}
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
@ -450,6 +590,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(applySearchFilter, 150);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === '/' && document.activeElement !== searchInput) {
|
||||
e.preventDefault();
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Select all checkbox
|
||||
|
||||
@ -18,7 +18,7 @@ ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"}
|
||||
class OrdreLineInput(BaseModel):
|
||||
line_key: str
|
||||
source_type: str
|
||||
source_id: int
|
||||
source_id: Optional[int] = None
|
||||
description: str
|
||||
quantity: float = Field(gt=0)
|
||||
unit_price: float = Field(ge=0)
|
||||
@ -45,6 +45,10 @@ class OrdreDraftUpsertRequest(BaseModel):
|
||||
layout_number: Optional[int] = None
|
||||
|
||||
|
||||
class OrdreDraftConsolidateRequest(BaseModel):
|
||||
draft_ids: List[int] = Field(..., min_length=2)
|
||||
|
||||
|
||||
def _safe_json_field(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
@ -572,3 +576,114 @@ async def delete_ordre_draft(draft_id: int, http_request: Request):
|
||||
except Exception as e:
|
||||
logger.error("❌ Error deleting ordre draft: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to delete ordre draft")
|
||||
|
||||
|
||||
@router.post("/ordre/drafts/consolidate")
|
||||
async def consolidate_ordre_drafts(payload: OrdreDraftConsolidateRequest, http_request: Request):
|
||||
"""Consolidate two or more drafts for the same customer into one draft."""
|
||||
try:
|
||||
draft_ids = sorted(set(int(x) for x in payload.draft_ids if int(x) > 0))
|
||||
if len(draft_ids) < 2:
|
||||
raise HTTPException(status_code=400, detail="Select at least two drafts to consolidate")
|
||||
|
||||
placeholders = ",".join(["%s"] * len(draft_ids))
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
|
||||
rows = execute_query(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM ordre_drafts
|
||||
WHERE id IN ({placeholders})
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
tuple(draft_ids),
|
||||
) or []
|
||||
|
||||
if len(rows) != len(draft_ids):
|
||||
raise HTTPException(status_code=404, detail="One or more drafts were not found")
|
||||
|
||||
customer_ids = {row.get("customer_id") for row in rows}
|
||||
if len(customer_ids) != 1:
|
||||
raise HTTPException(status_code=400, detail="Drafts must belong to the same customer")
|
||||
|
||||
customer_id = next(iter(customer_ids))
|
||||
if customer_id is None:
|
||||
raise HTTPException(status_code=400, detail="Drafts without customer_id cannot be consolidated")
|
||||
|
||||
blocked_statuses = {"exported", "posted", "paid"}
|
||||
for row in rows:
|
||||
sync_status = str(row.get("sync_status") or "pending").strip().lower()
|
||||
if sync_status in blocked_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Draft #{row.get('id')} has status '{sync_status}' and cannot be consolidated",
|
||||
)
|
||||
|
||||
primary = rows[0]
|
||||
secondary_ids = [int(row["id"]) for row in rows[1:]]
|
||||
|
||||
merged_lines: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
lines = _safe_json_field(row.get("lines_json")) or []
|
||||
if isinstance(lines, list):
|
||||
merged_lines.extend(lines)
|
||||
|
||||
notes_parts = [str(row.get("notes")).strip() for row in rows if row.get("notes")]
|
||||
merged_notes = "\n\n".join(part for part in notes_parts if part)
|
||||
|
||||
aggregate_key = primary.get("invoice_aggregate_key") or f"consolidated-customer-{customer_id}"
|
||||
keep_id = int(primary["id"])
|
||||
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE ordre_drafts
|
||||
SET lines_json = %s::jsonb,
|
||||
notes = %s,
|
||||
invoice_aggregate_key = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
json.dumps(merged_lines, ensure_ascii=False),
|
||||
merged_notes or None,
|
||||
aggregate_key,
|
||||
keep_id,
|
||||
),
|
||||
)
|
||||
|
||||
if secondary_ids:
|
||||
delete_placeholders = ",".join(["%s"] * len(secondary_ids))
|
||||
execute_query(
|
||||
f"DELETE FROM ordre_drafts WHERE id IN ({delete_placeholders})",
|
||||
tuple(secondary_ids),
|
||||
)
|
||||
|
||||
updated = execute_query_single("SELECT * FROM ordre_drafts WHERE id = %s", (keep_id,))
|
||||
|
||||
_log_sync_event(
|
||||
keep_id,
|
||||
"drafts_consolidated",
|
||||
primary.get("sync_status"),
|
||||
primary.get("sync_status"),
|
||||
{
|
||||
"merged_from": draft_ids,
|
||||
"kept_id": keep_id,
|
||||
"customer_id": customer_id,
|
||||
"line_count": len(merged_lines),
|
||||
},
|
||||
_get_user_id_from_request(http_request),
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"kept_draft_id": keep_id,
|
||||
"merged_draft_ids": draft_ids,
|
||||
"removed_draft_ids": secondary_ids,
|
||||
"line_count": len(merged_lines),
|
||||
"draft": updated,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error consolidating ordre drafts: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to consolidate ordre drafts")
|
||||
|
||||
@ -199,6 +199,15 @@
|
||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sourceBadge(type) {
|
||||
if (type === 'subscription') return '<span class="badge bg-primary line-source">Abonnement</span>';
|
||||
if (type === 'hardware') return '<span class="badge bg-secondary line-source">Hardware</span>';
|
||||
@ -335,9 +344,20 @@
|
||||
const index = line.originalIndex;
|
||||
const isManual = line.source_type === 'manual';
|
||||
const descriptionField = isManual
|
||||
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}"
|
||||
? `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
|
||||
onchange="ordreLines[${index}].description = this.value;">`
|
||||
: (line.description || '-');
|
||||
: escapeHtml(line.description || '-');
|
||||
|
||||
const manualActions = isManual
|
||||
? `
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="resolveManualLineProductByCode(${index})" title="Søg produkt via strengkode i APIGateway">
|
||||
<i class="bi bi-upc-scan"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
`
|
||||
: '-';
|
||||
|
||||
html += `
|
||||
<tr class="order-lines-container" data-order="order-${groupIndex}">
|
||||
@ -361,9 +381,7 @@
|
||||
</td>
|
||||
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
|
||||
<td>${renderExportStatusBadge(line)}</td>
|
||||
<td>
|
||||
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
|
||||
</td>
|
||||
<td>${manualActions}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
@ -415,6 +433,62 @@
|
||||
return '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
|
||||
}
|
||||
|
||||
async function resolveManualLineProductByCode(index) {
|
||||
const line = ordreLines[index];
|
||||
if (!line) return;
|
||||
|
||||
const defaultCode = String(line.ean || line.sku || line.product_code || '').trim();
|
||||
const code = prompt('Indtast EAN/strengkode', defaultCode || '');
|
||||
if (!code || !code.trim()) return;
|
||||
|
||||
try {
|
||||
const customerId = Number(document.getElementById('customerId')?.value || 0) || null;
|
||||
const params = new URLSearchParams({ code: code.trim(), auto_create: 'true' });
|
||||
if (customerId) {
|
||||
params.append('customer_id', String(customerId));
|
||||
}
|
||||
const response = await fetch(`/api/v1/products/search/apigateway-sync?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'APIGateway søgning fejlede');
|
||||
}
|
||||
|
||||
if (!data.found || !data.product) {
|
||||
alert('Ingen vare fundet på den strengkode');
|
||||
return;
|
||||
}
|
||||
|
||||
const product = data.product;
|
||||
line.product_id = product.id || line.product_id || null;
|
||||
line.description = product.name || product.product_name || line.description || '';
|
||||
if (!Number(line.unit_price || 0) && product.sales_price != null) {
|
||||
line.unit_price = Number(product.sales_price || 0);
|
||||
}
|
||||
if (product.ean) {
|
||||
line.ean = product.ean;
|
||||
}
|
||||
line.product_code = code.trim();
|
||||
updateLineAmount(index);
|
||||
|
||||
if (data.created) {
|
||||
if (data.applied_customer_margin_percent != null) {
|
||||
alert(`Produkt oprettet med kundeavance ${data.applied_customer_margin_percent}%`);
|
||||
} else {
|
||||
alert('Produkt fundet i APIGateway og oprettet lokalt på linjen');
|
||||
}
|
||||
} else if (data.source === 'local') {
|
||||
alert('Produkt fundet lokalt og sat på linjen');
|
||||
} else {
|
||||
alert('Produkt fundet via APIGateway og sat på linjen');
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message || 'Søgning fejlede');
|
||||
}
|
||||
}
|
||||
|
||||
function selectCustomer(customer) {
|
||||
document.getElementById('customerId').value = customer.id;
|
||||
document.getElementById('customerSearch').value = customer.name || '';
|
||||
|
||||
@ -377,11 +377,10 @@
|
||||
}
|
||||
|
||||
tbody.innerHTML = orderLines.map((line, index) => {
|
||||
const isManual = line.source_type === 'manual';
|
||||
const descriptionField = isManual
|
||||
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}"
|
||||
onchange="orderLines[${index}].description = this.value;">`
|
||||
: (line.description || '-');
|
||||
const isExportedLine = line.export_status === 'exported';
|
||||
const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].description = this.value;">`;
|
||||
|
||||
const exportStatus = line.export_status || '-';
|
||||
const statusBadge = exportStatus === 'exported'
|
||||
@ -390,28 +389,40 @@
|
||||
? '<span class="badge bg-warning text-dark">Dry-run</span>'
|
||||
: '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
|
||||
|
||||
const lineActions = isExportedLine
|
||||
? '-'
|
||||
: `
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="resolveDetailLineProductByCode(${index})" title="Søg produkt via strengkode i APIGateway">
|
||||
<i class="bi bi-upc-scan"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${sourceBadge(line.source_type)}</td>
|
||||
<td>${descriptionField}</td>
|
||||
<td style="min-width:100px;">
|
||||
<input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
|
||||
</td>
|
||||
<td style="min-width:120px;">
|
||||
<input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
|
||||
</td>
|
||||
<td style="min-width:110px;">
|
||||
<input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
|
||||
</td>
|
||||
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
|
||||
<td>${line.unit || 'stk'}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>
|
||||
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
|
||||
</td>
|
||||
<td>${lineActions}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
@ -461,6 +472,61 @@
|
||||
renderLines();
|
||||
}
|
||||
|
||||
async function resolveDetailLineProductByCode(index) {
|
||||
const line = orderLines[index];
|
||||
if (!line) return;
|
||||
|
||||
const defaultCode = String(line.ean || line.sku || line.product_code || '').trim();
|
||||
const code = prompt('Indtast EAN/strengkode', defaultCode || '');
|
||||
if (!code || !code.trim()) return;
|
||||
|
||||
try {
|
||||
const customerId = Number(document.getElementById('customerId')?.value || 0) || null;
|
||||
const params = new URLSearchParams({ code: code.trim(), auto_create: 'true' });
|
||||
if (customerId) {
|
||||
params.append('customer_id', String(customerId));
|
||||
}
|
||||
const response = await fetch(`/api/v1/products/search/apigateway-sync?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'APIGateway søgning fejlede');
|
||||
}
|
||||
if (!data.found || !data.product) {
|
||||
showToast('Ingen vare fundet på strengkoden', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const product = data.product;
|
||||
line.product_id = product.id || line.product_id || null;
|
||||
line.description = product.name || product.product_name || line.description || '';
|
||||
if (!Number(line.unit_price || 0) && product.sales_price != null) {
|
||||
line.unit_price = Number(product.sales_price || 0);
|
||||
}
|
||||
if (product.ean) {
|
||||
line.ean = product.ean;
|
||||
}
|
||||
line.product_code = code.trim();
|
||||
|
||||
updateLineAmount(index);
|
||||
if (data.created) {
|
||||
if (data.applied_customer_margin_percent != null) {
|
||||
showToast(`Produkt oprettet med kundeavance ${data.applied_customer_margin_percent}%`, 'success');
|
||||
} else {
|
||||
showToast('Produkt hentet fra APIGateway og oprettet lokalt', 'success');
|
||||
}
|
||||
} else if (data.source === 'local') {
|
||||
showToast('Produkt fundet lokalt og tilknyttet linjen', 'success');
|
||||
} else {
|
||||
showToast('Produkt fundet via APIGateway og tilknyttet linjen', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message || 'Søgning fejlede', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOrderLine(line) {
|
||||
// Handle e-conomic format (product.description, unitNetPrice, etc.)
|
||||
if (line.product && line.product.description && !line.description) {
|
||||
|
||||
@ -82,6 +82,7 @@
|
||||
<option value="paid">paid</option>
|
||||
</select>
|
||||
<span id="selectedCountBadge" class="selected-counter">Valgte: 0</span>
|
||||
<button class="btn btn-outline-secondary" onclick="consolidateSelectedByCustomer()"><i class="bi bi-collection me-1"></i>Konsolider valgte</button>
|
||||
<button class="btn btn-outline-success" onclick="markSelectedOrdersPaid()"><i class="bi bi-cash-stack me-1"></i>Markér valgte som betalt</button>
|
||||
<button class="btn btn-outline-primary" onclick="loadOrders()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
|
||||
</div>
|
||||
@ -400,6 +401,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function consolidateSelectedByCustomer() {
|
||||
const ids = Array.from(selectedOrderIds).map(Number).filter(Boolean);
|
||||
if (ids.length < 2) {
|
||||
showToast('Vælg mindst to ordrer for at konsolidere', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOrders = orders.filter(order => ids.includes(order.id));
|
||||
const groups = {};
|
||||
selectedOrders.forEach(order => {
|
||||
const key = order.customer_id || 'none';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(order.id);
|
||||
});
|
||||
|
||||
const eligibleGroups = Object.entries(groups)
|
||||
.filter(([key, groupIds]) => key !== 'none' && groupIds.length >= 2);
|
||||
|
||||
if (!eligibleGroups.length) {
|
||||
showToast('Ingen valgte ordrer kan konsolideres (kræver samme kunde)', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const totalDrafts = eligibleGroups.reduce((sum, [, groupIds]) => sum + groupIds.length, 0);
|
||||
if (!confirm(`Konsolider ${totalDrafts} valgte ordrer fordelt på ${eligibleGroups.length} kunde-grupper?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const failures = [];
|
||||
|
||||
for (const [customerId, groupIds] of eligibleGroups) {
|
||||
try {
|
||||
const res = await fetch('/api/v1/ordre/drafts/consolidate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ draft_ids: groupIds }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Konsolidering fejlede');
|
||||
results.push({ customerId, kept: data.kept_draft_id, merged: groupIds.length });
|
||||
} catch (error) {
|
||||
failures.push({ customerId, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
await loadOrders();
|
||||
|
||||
if (results.length) {
|
||||
showToast(`${results.length} kunde-grupper konsolideret`, 'success');
|
||||
}
|
||||
if (failures.length) {
|
||||
const msg = failures.map(f => `Kunde ${f.customerId}: ${f.error}`).join(' | ');
|
||||
showToast(msg, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQuickSyncStatus(orderId) {
|
||||
const select = document.getElementById(`syncStatus-${orderId}`);
|
||||
const syncStatus = (select?.value || '').trim().toLowerCase();
|
||||
|
||||
@ -3,7 +3,7 @@ import logging
|
||||
import base64
|
||||
import ipaddress
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from urllib.error import URLError, HTTPError
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
@ -210,7 +210,7 @@ async def yealink_established(
|
||||
|
||||
def _is_external_number(value: Optional[str]) -> bool:
|
||||
d = digits_only(value)
|
||||
return 8 <= len(d) <= 15
|
||||
return len(d) >= 8
|
||||
|
||||
def _is_internal_number(value: Optional[str], local_ext: Optional[str]) -> bool:
|
||||
d = digits_only(value)
|
||||
@ -221,21 +221,6 @@ async def yealink_established(
|
||||
return True
|
||||
return len(d) <= 6
|
||||
|
||||
def _strip_local_suffix(value: Optional[str], local_ext: Optional[str]) -> Optional[str]:
|
||||
"""If a number is too long (> 15 E.164 digits), try stripping the local extension
|
||||
from the end. This fixes Yealink Action URL misconfiguration where $caller$local
|
||||
gets concatenated without a separator."""
|
||||
if not value or not local_ext:
|
||||
return value
|
||||
d = digits_only(value)
|
||||
local_d = digits_only(local_ext)
|
||||
if len(d) > 15 and local_d and d.endswith(local_d):
|
||||
stripped = d[: len(d) - len(local_d)]
|
||||
if len(stripped) >= 4:
|
||||
# Re-apply leading '+' if original had it
|
||||
return ("+" + stripped) if value.lstrip().startswith("+") else stripped
|
||||
return value
|
||||
|
||||
local_value = _sanitize(local) or _sanitize(active_user)
|
||||
caller_value = _sanitize(caller) or _sanitize(remote)
|
||||
callee_value = _sanitize(callee)
|
||||
@ -243,12 +228,6 @@ async def yealink_established(
|
||||
|
||||
local_extension = extract_extension(local_value) or local_value
|
||||
|
||||
# Fix Yealink misconfiguration where caller number has the local phone number
|
||||
# concatenated at the end (e.g. "$caller$local" without a separator in the URL template).
|
||||
caller_value = _strip_local_suffix(caller_value, local_extension) or caller_value
|
||||
if _sanitize(remote):
|
||||
remote = _strip_local_suffix(_sanitize(remote), local_extension) or remote
|
||||
|
||||
is_outbound = False
|
||||
if called_number_value and _is_external_number(called_number_value):
|
||||
is_outbound = True
|
||||
@ -674,29 +653,15 @@ async def list_calls(
|
||||
where = []
|
||||
params = []
|
||||
|
||||
parsed_date_from = None
|
||||
parsed_date_to = None
|
||||
if date_from:
|
||||
try:
|
||||
parsed_date_from = datetime.strptime(date_from, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="Invalid date_from format, expected YYYY-MM-DD")
|
||||
if date_to:
|
||||
try:
|
||||
# Make date_to inclusive for the whole selected day.
|
||||
parsed_date_to = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="Invalid date_to format, expected YYYY-MM-DD")
|
||||
|
||||
if user_id is not None:
|
||||
where.append("t.bruger_id = %s")
|
||||
params.append(user_id)
|
||||
if parsed_date_from is not None:
|
||||
if date_from:
|
||||
where.append("t.started_at >= %s")
|
||||
params.append(parsed_date_from)
|
||||
if parsed_date_to is not None:
|
||||
where.append("t.started_at < %s")
|
||||
params.append(parsed_date_to)
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
where.append("t.started_at <= %s")
|
||||
params.append(date_to)
|
||||
if without_case:
|
||||
where.append("t.sag_id IS NULL")
|
||||
|
||||
|
||||
@ -58,42 +58,8 @@
|
||||
<th class="text-end">Varighed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="telefoniRows" data-initial-count="{{ initial_calls|length if initial_calls else 0 }}">
|
||||
{% if initial_calls and initial_calls|length > 0 %}
|
||||
{% for r in initial_calls %}
|
||||
<tr>
|
||||
<td>{{ r.started_at or '-' }}</td>
|
||||
<td>{{ r.full_name or r.username or '-' }}</td>
|
||||
<td>{{ 'Udgående' if r.direction == 'outbound' else 'Indgående' }}</td>
|
||||
<td>{{ r.display_number or '-' }}</td>
|
||||
<td>
|
||||
{% if r.kontakt_id %}
|
||||
<a href="/contacts/{{ r.kontakt_id }}">{{ r.contact_name or ('Kontakt #' ~ r.kontakt_id) }}</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if r.sag_id %}
|
||||
<a href="/sag/{{ r.sag_id }}/v3">{{ r.sag_titel or ('Sag #' ~ r.sag_id) }}</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if r.duration_sec is not none %}
|
||||
{{ r.duration_sec }}s
|
||||
{% elif r.ended_at %}
|
||||
-
|
||||
{% else %}
|
||||
I gang
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tbody id="telefoniRows">
|
||||
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -212,9 +178,6 @@ function escapeHtml(str) {
|
||||
}
|
||||
|
||||
let telefoniCurrentUserId = null;
|
||||
let telefoniAutoResetTried = false;
|
||||
let telefoniFirstApiLoadDone = false;
|
||||
let telefoniFiltersArmed = false;
|
||||
const telefoniCallMap = new Map();
|
||||
const linkSagState = {
|
||||
callId: null,
|
||||
@ -349,34 +312,20 @@ function renderLinkContactResults(results) {
|
||||
async function searchContacts(query) {
|
||||
const token = ++linkContactState.searchToken;
|
||||
const container = document.getElementById('linkContactResults');
|
||||
const trimmed = String(query || '').trim();
|
||||
|
||||
if (trimmed.length < 2) {
|
||||
renderLinkContactResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
|
||||
}
|
||||
|
||||
try {
|
||||
const qs = new URLSearchParams({ q: trimmed });
|
||||
let res = await fetch(`/api/v1/search/contacts?${qs.toString()}`, { credentials: 'include' });
|
||||
|
||||
// Backward compatible fallback in case search router is unavailable.
|
||||
if (!res.ok && (res.status === 404 || res.status === 405)) {
|
||||
const legacyQs = new URLSearchParams({ search: trimmed, limit: '30', offset: '0' });
|
||||
res = await fetch(`/api/v1/contacts?${legacyQs.toString()}`, { credentials: 'include' });
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams({ search: query || '', limit: '30', offset: '0' });
|
||||
const res = await fetch(`/api/v1/contacts?${qs.toString()}`, { credentials: 'include' });
|
||||
if (token !== linkContactState.searchToken) return;
|
||||
if (!res.ok) {
|
||||
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge kontakter</div>';
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const results = Array.isArray(data) ? data : (Array.isArray(data?.contacts) ? data.contacts : []);
|
||||
const results = Array.isArray(data?.contacts) ? data.contacts : [];
|
||||
renderLinkContactResults(results);
|
||||
} catch (e) {
|
||||
if (token !== linkContactState.searchToken) return;
|
||||
@ -644,47 +593,6 @@ function openLinkContactModal(callId, mode = 'contact') {
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function quickNewContact(callId, number) {
|
||||
// Open the link-contact modal but scroll to / pre-fill the 'opret ny kontakt' form.
|
||||
linkContactState.callId = Number(callId);
|
||||
linkContactState.number = String(number || '').trim();
|
||||
linkContactState.mode = 'contact';
|
||||
|
||||
const ctx = document.getElementById('linkContactContext');
|
||||
const phoneInput = document.getElementById('newContactPhone');
|
||||
const firstNameInput = document.getElementById('newContactFirstName');
|
||||
const lastNameInput = document.getElementById('newContactLastName');
|
||||
const emailInput = document.getElementById('newContactEmail');
|
||||
const titleInput = document.getElementById('newContactTitle');
|
||||
const searchInput = document.getElementById('linkContactSearch');
|
||||
const companySearchInput = document.getElementById('linkCompanySearch');
|
||||
|
||||
if (ctx) ctx.textContent = `Opkald: ${linkContactState.number || ('#' + callId)}`;
|
||||
if (phoneInput) phoneInput.value = linkContactState.number;
|
||||
if (firstNameInput) firstNameInput.value = '';
|
||||
if (lastNameInput) lastNameInput.value = '';
|
||||
if (emailInput) emailInput.value = '';
|
||||
if (titleInput) titleInput.value = '';
|
||||
if (searchInput) searchInput.value = '';
|
||||
if (companySearchInput) companySearchInput.value = '';
|
||||
setLinkContactSelected(null, '');
|
||||
setLinkCompanySelected(null, '');
|
||||
renderLinkContactResults([]);
|
||||
renderCompanyResults([]);
|
||||
|
||||
const modal = getLinkContactModalInstance();
|
||||
if (modal) modal.show();
|
||||
|
||||
// Scroll to the "Opret ny kontakt" section after modal opens
|
||||
setTimeout(() => {
|
||||
const newSection = document.getElementById('newContactFirstName');
|
||||
if (newSection) {
|
||||
newSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
newSection.focus();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function unlinkContact(callId) {
|
||||
if (!confirm('Fjern link til kontakt for dette opkald?')) return;
|
||||
try {
|
||||
@ -914,32 +822,12 @@ async function loadUsers() {
|
||||
|
||||
async function loadCalls() {
|
||||
const tbody = document.getElementById('telefoniRows');
|
||||
const initialCount = Number(tbody?.dataset?.initialCount || '0');
|
||||
const hadServerRows = Number.isFinite(initialCount) && initialCount > 0;
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</td></tr>';
|
||||
|
||||
const userEl = document.getElementById('filterUser');
|
||||
const fromEl = document.getElementById('filterFrom');
|
||||
const toEl = document.getElementById('filterTo');
|
||||
const withoutCaseEl = document.getElementById('filterWithoutCase');
|
||||
|
||||
let userId = userEl.value;
|
||||
let from = fromEl.value;
|
||||
let to = toEl.value;
|
||||
let withoutCase = withoutCaseEl.checked;
|
||||
|
||||
// On first automatic load, ignore browser-restored filter values.
|
||||
// Filters are only applied after explicit user interaction.
|
||||
if (!telefoniFiltersArmed) {
|
||||
userId = '';
|
||||
from = '';
|
||||
to = '';
|
||||
withoutCase = false;
|
||||
userEl.value = '';
|
||||
fromEl.value = '';
|
||||
toEl.value = '';
|
||||
withoutCaseEl.checked = false;
|
||||
}
|
||||
const userId = document.getElementById('filterUser').value;
|
||||
const from = document.getElementById('filterFrom').value;
|
||||
const to = document.getElementById('filterTo').value;
|
||||
const withoutCase = document.getElementById('filterWithoutCase').checked;
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
if (userId) qs.set('user_id', userId);
|
||||
@ -958,34 +846,10 @@ async function loadCalls() {
|
||||
telefoniCallMap.clear();
|
||||
(rows || []).forEach(r => telefoniCallMap.set(Number(r.id), r));
|
||||
if (!rows || rows.length === 0) {
|
||||
const hadFilters = Boolean(userId || from || to || withoutCase);
|
||||
|
||||
// If SSR already showed calls, avoid replacing them with an empty first auto-refresh.
|
||||
if (!telefoniFirstApiLoadDone && hadServerRows && !hadFilters) {
|
||||
telefoniFirstApiLoadDone = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hadFilters && !telefoniAutoResetTried) {
|
||||
telefoniAutoResetTried = true;
|
||||
document.getElementById('filterUser').value = '';
|
||||
document.getElementById('filterFrom').value = '';
|
||||
document.getElementById('filterTo').value = '';
|
||||
document.getElementById('filterWithoutCase').checked = false;
|
||||
await loadCalls();
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small">Ingen opkald fundet</td></tr>';
|
||||
telefoniFirstApiLoadDone = true;
|
||||
return;
|
||||
}
|
||||
|
||||
telefoniAutoResetTried = false;
|
||||
telefoniFirstApiLoadDone = true;
|
||||
if (!telefoniFiltersArmed) {
|
||||
telefoniFiltersArmed = true;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rows.map(r => {
|
||||
const started = r.started_at ? new Date(r.started_at) : null;
|
||||
const dateTxt = started ? started.toLocaleString('da-DK') : '-';
|
||||
@ -1002,25 +866,13 @@ async function loadCalls() {
|
||||
const contactHtml = r.kontakt_id
|
||||
? `<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<a href="/contacts/${r.kontakt_id}">${escapeHtml(r.contact_name || ('Kontakt #' + r.kontakt_id))}</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary px-2 py-1" onclick="openLinkContactModal(${Number(r.id)})" title="Skift kontakt" aria-label="Skift kontakt">
|
||||
<i class="bi bi-pencil-square"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger px-2 py-1" onclick="unlinkContact(${Number(r.id)})" title="Fjern kontakt-link" aria-label="Fjern kontakt-link">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${Number(r.id)})">Skift</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact(${Number(r.id)})">Fjern</button>
|
||||
</div>
|
||||
${r.contact_company ? `<div class="text-muted small">${escapeHtml(r.contact_company)}</div>` : ''}`
|
||||
: `<div class="d-flex gap-1 flex-wrap">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary px-2 py-1" onclick="quickNewContact(${Number(r.id)}, '${escapeHtml(numberRaw)}')" title="Ny kontakt" aria-label="Ny kontakt">
|
||||
<i class="bi bi-person-plus"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary px-2 py-1" onclick="openLinkContactModal(${Number(r.id)})" title="Søg kontakt" aria-label="Søg kontakt">
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning px-2 py-1" onclick="openLinkContactModal(${Number(r.id)}, 'company')" title="Knyt firma" aria-label="Knyt firma">
|
||||
<i class="bi bi-building"></i>
|
||||
</button>
|
||||
</div>`;
|
||||
: `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${Number(r.id)})" title="Vælg kontakt/firma">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</button>`;
|
||||
|
||||
const numberForTitle = (r.display_number || r.ekstern_nummer || '').trim();
|
||||
const createQs = new URLSearchParams();
|
||||
@ -1113,38 +965,22 @@ async function unlinkCase(callId) {
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
initLinkContactModalEvents();
|
||||
initLinkSagModalEvents();
|
||||
|
||||
const userFilter = document.getElementById('filterUser');
|
||||
const fromFilter = document.getElementById('filterFrom');
|
||||
const toFilter = document.getElementById('filterTo');
|
||||
const withoutCaseFilter = document.getElementById('filterWithoutCase');
|
||||
const tbody = document.getElementById('telefoniRows');
|
||||
const ssrCount = Number(tbody?.dataset?.initialCount || '0');
|
||||
|
||||
if (userFilter) userFilter.value = '';
|
||||
if (fromFilter) fromFilter.value = '';
|
||||
if (toFilter) toFilter.value = '';
|
||||
if (withoutCaseFilter) withoutCaseFilter.checked = false;
|
||||
telefoniAutoResetTried = false;
|
||||
// Filters are already cleared above so we can arm immediately.
|
||||
telefoniFiltersArmed = true;
|
||||
|
||||
await loadUsers();
|
||||
|
||||
document.getElementById('btnRefresh').addEventListener('click', () => loadCalls());
|
||||
document.getElementById('filterUser').addEventListener('change', () => loadCalls());
|
||||
document.getElementById('filterFrom').addEventListener('change', () => loadCalls());
|
||||
document.getElementById('filterTo').addEventListener('change', () => loadCalls());
|
||||
document.getElementById('filterWithoutCase').addEventListener('change', () => loadCalls());
|
||||
|
||||
if (ssrCount > 0) {
|
||||
// SSR already rendered rows - no need for an extra API round-trip.
|
||||
// loadCalls() will fire when the user interacts with filters or Refresh.
|
||||
telefoniFirstApiLoadDone = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// SSR produced no rows (DB error or truly empty) - load via JS.
|
||||
document.getElementById('btnRefresh').addEventListener('click', loadCalls);
|
||||
document.getElementById('filterUser').addEventListener('change', loadCalls);
|
||||
document.getElementById('filterFrom').addEventListener('change', loadCalls);
|
||||
document.getElementById('filterTo').addEventListener('change', loadCalls);
|
||||
document.getElementById('filterWithoutCase').addEventListener('change', loadCalls);
|
||||
await loadCalls();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -210,7 +210,7 @@
|
||||
<div class="products-search">
|
||||
<div class="input-group" style="min-width: 260px;">
|
||||
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="localSearchQuery" placeholder="Soeg lokale produkter...">
|
||||
<input type="text" class="form-control" id="localSearchQuery" placeholder="Soeg lokale produkter eller EAN/strengkode...">
|
||||
</div>
|
||||
<select class="form-select" id="localSearchStatus" style="max-width: 140px;">
|
||||
<option value="active" selected>Aktiv</option>
|
||||
@ -234,6 +234,9 @@
|
||||
<button class="btn btn-outline-primary" onclick="applyLocalProductSearch()">
|
||||
<i class="bi bi-search"></i> Soeg
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="searchAndCreateByGatewayCode()" id="gatewayBarcodeSyncBtn">
|
||||
<i class="bi bi-upc-scan"></i> Soeg i APIGateway (strengkode)
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="clearLocalProductSearch()">
|
||||
<i class="bi bi-x"></i> Nulstil
|
||||
</button>
|
||||
@ -593,6 +596,67 @@ function applyLocalProductSearch() {
|
||||
loadProducts();
|
||||
}
|
||||
|
||||
function showLocalProductMessage(message, level = 'info') {
|
||||
const meta = document.getElementById('localProductsMeta');
|
||||
if (!meta) return;
|
||||
const prefix = level === 'error' ? 'Fejl: ' : level === 'success' ? 'OK: ' : '';
|
||||
meta.textContent = `${prefix}${message}`;
|
||||
}
|
||||
|
||||
async function searchAndCreateByGatewayCode() {
|
||||
const queryInput = document.getElementById('localSearchQuery');
|
||||
const button = document.getElementById('gatewayBarcodeSyncBtn');
|
||||
const code = queryInput ? queryInput.value.trim() : '';
|
||||
|
||||
if (!code) {
|
||||
alert('Skriv EAN eller strengkode i soegefeltet foerst');
|
||||
return;
|
||||
}
|
||||
|
||||
const oldHtml = button ? button.innerHTML : '';
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Soeger...';
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ code, auto_create: 'true' });
|
||||
const response = await fetch(`/api/v1/products/search/apigateway-sync?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body.detail || 'Sogning i APIGateway fejlede');
|
||||
}
|
||||
|
||||
if (!body.found) {
|
||||
showLocalProductMessage('Ingen match i APIGateway for den strengkode', 'error');
|
||||
alert('Ingen produkt fundet i APIGateway for den strengkode');
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.source === 'local') {
|
||||
showLocalProductMessage('Produkt fundet lokalt', 'success');
|
||||
} else if (body.created) {
|
||||
showLocalProductMessage('Produkt hentet fra APIGateway og oprettet lokalt', 'success');
|
||||
} else {
|
||||
showLocalProductMessage('Produkt fundet via APIGateway', 'info');
|
||||
}
|
||||
|
||||
await loadProducts();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showLocalProductMessage(error.message || 'Sogning fejlede', 'error');
|
||||
alert(error.message || 'Sogning fejlede');
|
||||
} finally {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = oldHtml || '<i class="bi bi-upc-scan"></i> Soeg i APIGateway (strengkode)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearLocalProductSearch() {
|
||||
const fields = [
|
||||
'localSearchQuery',
|
||||
@ -654,8 +718,12 @@ function setupLocalSearchEvents() {
|
||||
el.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (id === 'localSearchQuery' && (event.ctrlKey || event.metaKey)) {
|
||||
searchAndCreateByGatewayCode();
|
||||
} else {
|
||||
applyLocalProductSearch();
|
||||
}
|
||||
}
|
||||
});
|
||||
el.addEventListener('input', triggerSearch);
|
||||
});
|
||||
|
||||
@ -813,7 +813,6 @@
|
||||
<li><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/support/fedex"><i class="bi bi-truck me-2"></i>FedEx Overblik</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
|
||||
@ -846,7 +845,6 @@
|
||||
<i class="bi bi-currency-dollar me-2"></i>Økonomi
|
||||
</a>
|
||||
<ul class="dropdown-menu mt-2">
|
||||
<li><a class="dropdown-item py-2" href="/economy/time-queue"><i class="bi bi-clock-history me-2"></i>Time Queue</a></li>
|
||||
<li><a class="dropdown-item py-2" href="#">Fakturaer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
|
||||
@ -870,7 +868,6 @@
|
||||
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/wizard2"><i class="bi bi-check2-square me-2"></i>Godkend Tider V2</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
|
||||
@ -882,9 +879,6 @@
|
||||
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
|
||||
<i class="bi bi-plus-circle-fill fs-5"></i>
|
||||
</button>
|
||||
<button class="btn btn-light rounded-circle border-0" id="bugReportBtn" style="background: var(--accent-light); color: var(--accent);" title="Rapporter fejl (Ctrl+Shift+B)">
|
||||
<i class="bi bi-bug"></i>
|
||||
</button>
|
||||
<button class="btn btn-light rounded-circle border-0" id="contextManualBtn" style="background: var(--accent-light); color: var(--accent);" title="Kontekstuel hjælp">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</button>
|
||||
@ -1266,14 +1260,12 @@ window.addEventListener('unhandledrejection', function(event) {
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||
<script src="/static/js/tag-picker.js?v=2.2"></script>
|
||||
<script src="/static/js/task-template-selector.js?v=1.1"></script>
|
||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||
<script src="/static/js/telefoni.js?v=2.2"></script>
|
||||
<script src="/static/js/sms.js?v=1.0"></script>
|
||||
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
||||
<script src="/static/js/bug-report.js?v=1.6"></script>
|
||||
<script>
|
||||
// Dark Mode Toggle Logic
|
||||
window.BMC_CAN_CLICK_TO_CALL = {{ 'true' if _can_click_to_call else 'false' }};
|
||||
@ -2023,9 +2015,6 @@ window.addEventListener('unhandledrejection', function(event) {
|
||||
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
|
||||
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
|
||||
|
||||
<!-- Bug Report Modal -->
|
||||
{% include ["bug_report_modal.html", "shared/frontend/bug_report_modal.html"] ignore missing %}
|
||||
|
||||
<!-- Manual Help Modal -->
|
||||
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}
|
||||
|
||||
|
||||
@ -33,8 +33,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="bugCaptureDisplayMediaBtn">
|
||||
<i class="bi bi-display me-1"></i>Tag screenshot via skærmdeling
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="bugReportStatus" class="small text-muted mt-2"></div>
|
||||
<div class="small text-muted mt-2">Screenshot forsøges automatisk ved klik på bug-ikonet. Du kan også indsætte med Cmd+V.</div>
|
||||
<div class="small text-muted mt-2">Screenshot forsøges automatisk ved klik på bug-ikonet. Hvis det fejler, brug skærmdeling-knappen eller indsæt med Cmd+V.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||
|
||||
@ -222,6 +222,9 @@
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="collapseAll()">Fold alt sammen</button>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-primary" id="create-order-btn" onclick="createOrderFromApproved()">
|
||||
<i class="bi bi-receipt"></i> Opret ordre
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="rejectSelected()">
|
||||
<i class="bi bi-x-circle"></i> Afvis Valgte
|
||||
</button>
|
||||
@ -599,11 +602,20 @@
|
||||
function updateSummary() {
|
||||
const name = currentCustomerData?.customer_name || 'Kunde';
|
||||
const rate = currentCustomerData?.customer_rate || DEFAULT_RATE;
|
||||
const approvedCount = parseInt(currentCustomerData?.approved_count || 0, 10);
|
||||
const createOrderBtn = document.getElementById('create-order-btn');
|
||||
|
||||
document.getElementById('customer-name').textContent = name;
|
||||
document.getElementById('hourly-rate').textContent = parseFloat(rate).toFixed(2);
|
||||
document.getElementById('pending-count').textContent = pendingEntries.length;
|
||||
|
||||
if (createOrderBtn) {
|
||||
createOrderBtn.disabled = approvedCount <= 0;
|
||||
createOrderBtn.title = approvedCount > 0
|
||||
? `Opret ordre fra ${approvedCount} godkendte registreringer`
|
||||
: 'Ingen godkendte registreringer endnu';
|
||||
}
|
||||
|
||||
let totalValue = 0;
|
||||
pendingEntries.forEach(entry => {
|
||||
const hoursInput = document.getElementById(`hours-${entry.id}`);
|
||||
@ -835,6 +847,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrderFromApproved() {
|
||||
if (!currentCustomerId) {
|
||||
alert('Vælg først en kunde.');
|
||||
return;
|
||||
}
|
||||
|
||||
const approvedCount = parseInt(currentCustomerData?.approved_count || 0, 10);
|
||||
if (approvedCount <= 0) {
|
||||
alert('Der er ingen godkendte registreringer at oprette ordre fra.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Opret ordre fra ${approvedCount} godkendte registreringer?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = document.getElementById('create-order-btn');
|
||||
const originalText = button ? button.innerHTML : '';
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/timetracking/orders/generate/${currentCustomerId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(result.detail || 'Fejl ved oprettelse af ordre');
|
||||
}
|
||||
|
||||
alert(`✅ Ordre oprettet: ${result.order_number}\n\nTotal: ${parseFloat(result.total_amount || 0).toFixed(2)} DKK`);
|
||||
window.location.href = '/timetracking/orders';
|
||||
} catch (error) {
|
||||
alert('❌ Kunne ikke oprette ordre:\n\n' + error.message);
|
||||
} finally {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectSelected() {
|
||||
const ids = Array.from(selectedEntries);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
48
main.py
48
main.py
@ -139,9 +139,6 @@ from app.modules.bottom_bar.backend import router as bottom_bar_api
|
||||
from app.modules.bottom_bar.backend import public_router as bottom_bar_public_api
|
||||
from app.modules.rentals.backend import router as rentals_api
|
||||
from app.modules.task_templates.backend import router as task_templates_api
|
||||
from app.economy.backend import router as economy_api
|
||||
from app.economy.frontend import views as economy_views
|
||||
from app.bug_reports.backend import router as bug_reports_api
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@ -155,34 +152,6 @@ logging.basicConfig(
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _AccessLogPathFilter(logging.Filter):
|
||||
"""Filter out high-frequency access log noise for specific polling endpoints."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
try:
|
||||
target = "/api/v1/bottom-bar/state"
|
||||
|
||||
# Match rendered access message (works across uvicorn format variations).
|
||||
message = record.getMessage() or ""
|
||||
if f"GET {target}" in message or f'"GET {target}' in message:
|
||||
return False
|
||||
|
||||
# Fallback: match structured args when available.
|
||||
args = record.args if isinstance(record.args, tuple) else ()
|
||||
if len(args) >= 3:
|
||||
method = str(args[1])
|
||||
path = str(args[2])
|
||||
if method == "GET" and path.startswith(target):
|
||||
return False
|
||||
except Exception:
|
||||
# Never break logging on filter errors.
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
logging.getLogger("uvicorn.access").addFilter(_AccessLogPathFilter())
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Lifecycle management - startup and shutdown"""
|
||||
@ -488,8 +457,6 @@ app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bo
|
||||
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
|
||||
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
|
||||
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
|
||||
app.include_router(economy_api.router, prefix="/api/v1", tags=["Economy"])
|
||||
app.include_router(bug_reports_api.router, prefix="/api/v1", tags=["Bug Reports"])
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED:
|
||||
from app.modules.links.backend import router as links_api
|
||||
@ -525,7 +492,6 @@ app.include_router(orders_views.router, tags=["Frontend"])
|
||||
app.include_router(fedex_views.router, tags=["Frontend"])
|
||||
app.include_router(anydesk_views.router, tags=["Frontend"])
|
||||
app.include_router(manual_views.router, tags=["Frontend"])
|
||||
app.include_router(economy_views.router, tags=["Frontend"])
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED:
|
||||
from app.modules.links.frontend import views as links_views
|
||||
@ -546,20 +512,8 @@ async def health_check():
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
from copy import deepcopy
|
||||
from uvicorn.config import LOGGING_CONFIG as UVICORN_LOGGING_CONFIG
|
||||
import os
|
||||
|
||||
log_config = deepcopy(UVICORN_LOGGING_CONFIG)
|
||||
log_config.setdefault("filters", {})["suppress_bottom_bar_polling"] = {
|
||||
"()": _AccessLogPathFilter,
|
||||
}
|
||||
access_handler = log_config.get("handlers", {}).get("access", {})
|
||||
existing_filters = list(access_handler.get("filters", []))
|
||||
if "suppress_bottom_bar_polling" not in existing_filters:
|
||||
existing_filters.append("suppress_bottom_bar_polling")
|
||||
access_handler["filters"] = existing_filters
|
||||
|
||||
# Only enable reload in local development (not in Docker) - check both variables
|
||||
enable_reload = (
|
||||
os.getenv("ENABLE_RELOAD", "false").lower() == "true" or
|
||||
@ -575,7 +529,6 @@ if __name__ == "__main__":
|
||||
reload_includes=["*.py"],
|
||||
reload_dirs=["app"],
|
||||
reload_excludes=[".git/*", "*.pyc", "__pycache__/*", "logs/*", "uploads/*", "data/*"],
|
||||
log_config=log_config,
|
||||
log_level="info"
|
||||
)
|
||||
else:
|
||||
@ -587,6 +540,5 @@ if __name__ == "__main__":
|
||||
workers=2,
|
||||
timeout_keep_alive=65,
|
||||
access_log=True,
|
||||
log_config=log_config,
|
||||
log_level="info"
|
||||
)
|
||||
|
||||
9
migrations/184_locations_contacts_related_contact.sql
Normal file
9
migrations/184_locations_contacts_related_contact.sql
Normal file
@ -0,0 +1,9 @@
|
||||
-- Migration 184: Add optional relation to existing global contacts on location contacts
|
||||
-- Date: 2026-02-18
|
||||
-- Purpose: Allow location contact rows to reference contacts.id for explicit linkage
|
||||
|
||||
ALTER TABLE locations_contacts
|
||||
ADD COLUMN IF NOT EXISTS related_contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_locations_contacts_related_contact_id
|
||||
ON locations_contacts(related_contact_id);
|
||||
26
migrations/185_locations_contacts_unique_related.sql
Normal file
26
migrations/185_locations_contacts_unique_related.sql
Normal file
@ -0,0 +1,26 @@
|
||||
-- Migration 185: Prevent duplicate active location-contact relations
|
||||
-- Date: 2026-05-05
|
||||
-- Purpose: Ensure one active relation per (location_id, related_contact_id)
|
||||
|
||||
-- Soft-delete duplicate active rows, keep best candidate (primary first, then oldest)
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY location_id, related_contact_id
|
||||
ORDER BY is_primary DESC, created_at ASC, id ASC
|
||||
) AS rn
|
||||
FROM locations_contacts
|
||||
WHERE deleted_at IS NULL
|
||||
AND related_contact_id IS NOT NULL
|
||||
)
|
||||
UPDATE locations_contacts lc
|
||||
SET deleted_at = NOW()
|
||||
FROM ranked r
|
||||
WHERE lc.id = r.id
|
||||
AND r.rn > 1;
|
||||
|
||||
-- Enforce uniqueness for active linked contacts
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_locations_contacts_location_related_active
|
||||
ON locations_contacts(location_id, related_contact_id)
|
||||
WHERE deleted_at IS NULL AND related_contact_id IS NOT NULL;
|
||||
16
migrations/186_customers_economic_pricing_fields.sql
Normal file
16
migrations/186_customers_economic_pricing_fields.sql
Normal file
@ -0,0 +1,16 @@
|
||||
-- Migration 186: Customer-specific economic pricing fields
|
||||
-- Adds customer-level defaults for margin, freight, supplier service flag, and invoice fee.
|
||||
|
||||
ALTER TABLE customers
|
||||
ADD COLUMN IF NOT EXISTS standard_margin_percent NUMERIC(5,2) NOT NULL DEFAULT 20.00,
|
||||
ADD COLUMN IF NOT EXISTS special_freight_price NUMERIC(10,2),
|
||||
ADD COLUMN IF NOT EXISTS supplier_service_enrolled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS invoice_fee_amount NUMERIC(10,2) NOT NULL DEFAULT 49.00;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_supplier_service_enrolled
|
||||
ON customers(supplier_service_enrolled);
|
||||
|
||||
COMMENT ON COLUMN customers.standard_margin_percent IS 'Default margin percentage used for order pricing.';
|
||||
COMMENT ON COLUMN customers.special_freight_price IS 'Customer-specific freight price override.';
|
||||
COMMENT ON COLUMN customers.supplier_service_enrolled IS 'Whether customer is enrolled in supplier service.';
|
||||
COMMENT ON COLUMN customers.invoice_fee_amount IS 'Customer-specific invoice fee amount. 0 disables invoice fee.';
|
||||
7
migrations/187_customers_standard_hourly_rate.sql
Normal file
7
migrations/187_customers_standard_hourly_rate.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- Migration 187: Customer-specific standard hourly rate
|
||||
-- Allows setting default time price per customer on customer profile.
|
||||
|
||||
ALTER TABLE customers
|
||||
ADD COLUMN IF NOT EXISTS standard_hourly_rate NUMERIC(10,2) NOT NULL DEFAULT 1200.00;
|
||||
|
||||
COMMENT ON COLUMN customers.standard_hourly_rate IS 'Customer-specific default hourly rate used in time-to-order pricing.';
|
||||
@ -4,6 +4,7 @@
|
||||
let bugModal = null;
|
||||
let screenshotDataUrl = null;
|
||||
let pendingScreenshotPromise = null;
|
||||
let isCapturingDisplayMedia = false;
|
||||
|
||||
const pushLog = (type, args) => {
|
||||
try {
|
||||
@ -206,6 +207,9 @@
|
||||
});
|
||||
} catch (e1) {
|
||||
console.warn('Bug report screenshot strategy 1 failed', e1);
|
||||
if (String(e1?.message || '').toLowerCase().includes('unsupported color function "color"')) {
|
||||
throw new Error('Html2canvas understøtter ikke denne browsers farveprofil (color()).');
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Main content only (explicit selectors avoid navbar-only captures)
|
||||
@ -227,6 +231,9 @@
|
||||
});
|
||||
} catch (e2) {
|
||||
console.warn('Bug report screenshot strategy 2 failed', e2);
|
||||
if (String(e2?.message || '').toLowerCase().includes('unsupported color function "color"')) {
|
||||
throw new Error('Html2canvas understøtter ikke denne browsers farveprofil (color()).');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Automatic screenshot failed');
|
||||
@ -296,7 +303,7 @@
|
||||
setStatus('Screenshot klar. Udfyld felterne og send.');
|
||||
} catch (e) {
|
||||
console.warn('Bug report screenshot failed', e);
|
||||
setStatus('Kunne ikke tage screenshot automatisk. Vedhæft et billede manuelt eller indsæt med Cmd+V.', true);
|
||||
setStatus('Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.', true);
|
||||
} finally {
|
||||
pendingScreenshotPromise = null;
|
||||
}
|
||||
@ -305,17 +312,39 @@
|
||||
}
|
||||
|
||||
function prepareScreenshotFromTrigger() {
|
||||
pendingScreenshotPromise = (async () => {
|
||||
try {
|
||||
return await takeScreenshot();
|
||||
} catch (e) {
|
||||
console.warn('Bug report html2canvas capture failed, trying display media', e);
|
||||
return await takeScreenshotViaDisplayMedia();
|
||||
}
|
||||
})();
|
||||
pendingScreenshotPromise = takeScreenshot();
|
||||
return pendingScreenshotPromise;
|
||||
}
|
||||
|
||||
async function captureScreenshotViaDisplayMediaFromUserGesture() {
|
||||
if (isCapturingDisplayMedia) return;
|
||||
|
||||
const btn = document.getElementById('bugCaptureDisplayMediaBtn');
|
||||
const originalHtml = btn ? btn.innerHTML : '';
|
||||
isCapturingDisplayMedia = true;
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Venter på skærmvalg...';
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await takeScreenshotViaDisplayMedia();
|
||||
screenshotDataUrl = dataUrl;
|
||||
setPreview(dataUrl);
|
||||
setStatus('Screenshot taget via skærmdeling.');
|
||||
} catch (e) {
|
||||
console.warn('Bug report display media capture failed', e);
|
||||
setStatus('Skærmdelings-screenshot mislykkedes. Prøv igen eller indsæt med Cmd+V.', true);
|
||||
} finally {
|
||||
isCapturingDisplayMedia = false;
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml || '<i class="bi bi-display me-1"></i>Tag screenshot via skærmdeling';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitBugReport() {
|
||||
const actual = (document.getElementById('bugActualInput')?.value || '').trim();
|
||||
const expected = (document.getElementById('bugExpectedInput')?.value || '').trim();
|
||||
@ -401,6 +430,7 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btn = document.getElementById('bugReportBtn');
|
||||
const submitBtn = document.getElementById('bugReportSubmitBtn');
|
||||
const displayMediaBtn = document.getElementById('bugCaptureDisplayMediaBtn');
|
||||
const modalEl = document.getElementById('bugReportModal');
|
||||
|
||||
if (btn) {
|
||||
@ -419,6 +449,13 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (displayMediaBtn) {
|
||||
displayMediaBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
captureScreenshotViaDisplayMediaFromUserGesture();
|
||||
});
|
||||
}
|
||||
|
||||
if (modalEl) {
|
||||
modalEl.addEventListener('hidden.bs.modal', () => {
|
||||
const actual = document.getElementById('bugActualInput');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user