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
|
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)
|
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 (CVR company lookup)
|
||||||
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
|
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
|
||||||
FIRMAAPI_API_KEY=
|
FIRMAAPI_API_KEY=
|
||||||
|
|||||||
@ -44,6 +44,11 @@ API_HOST=0.0.0.0
|
|||||||
API_PORT=8000
|
API_PORT=8000
|
||||||
API_RELOAD=false
|
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 (CVR company lookup)
|
||||||
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
|
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
|
||||||
FIRMAAPI_API_KEY=
|
FIRMAAPI_API_KEY=
|
||||||
|
|||||||
@ -160,8 +160,8 @@
|
|||||||
.contacts-table-wrap {
|
.contacts-table-wrap {
|
||||||
border: 1px solid rgba(15, 76, 117, 0.12);
|
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow-x: auto;
|
max-height: min(68vh, 780px);
|
||||||
overflow-y: visible;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contacts-table {
|
.contacts-table {
|
||||||
@ -813,6 +813,7 @@ let searchQuery = '';
|
|||||||
let totalContacts = 0;
|
let totalContacts = 0;
|
||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
let currentRequestController = null;
|
let currentRequestController = null;
|
||||||
|
let lastLoadedQueryKey = '';
|
||||||
let availableCompanies = [];
|
let availableCompanies = [];
|
||||||
let selectedCompanyIds = new Set();
|
let selectedCompanyIds = new Set();
|
||||||
let currentContactsData = [];
|
let currentContactsData = [];
|
||||||
@ -940,6 +941,12 @@ async function loadContacts() {
|
|||||||
params.append('is_active', 'false');
|
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 response = await fetch(`/api/v1/contacts?${params}`, { signal: currentRequestController.signal });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
@ -982,11 +989,9 @@ function displayContacts(contacts) {
|
|||||||
|
|
||||||
const companyCount = contact.company_count || 0;
|
const companyCount = contact.company_count || 0;
|
||||||
const companyNames = contact.company_names || [];
|
const companyNames = contact.company_names || [];
|
||||||
const fallbackCompany = (contact.user_company || '').trim();
|
|
||||||
const companyDisplay = companyNames.length > 0
|
const companyDisplay = companyNames.length > 0
|
||||||
? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '')
|
? 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 fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
|
||||||
const preferredPhone = contact.mobile || contact.phone || '';
|
const preferredPhone = contact.mobile || contact.phone || '';
|
||||||
const hasEmail = !!contact.email;
|
const hasEmail = !!contact.email;
|
||||||
@ -996,7 +1001,7 @@ function displayContacts(contacts) {
|
|||||||
const safeEmail = escapeHtml(contact.email || '-');
|
const safeEmail = escapeHtml(contact.email || '-');
|
||||||
const safeTitle = escapeHtml(contact.title || '-');
|
const safeTitle = escapeHtml(contact.title || '-');
|
||||||
const safePhone = escapeHtml(preferredPhone || '-');
|
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);
|
const updatedAt = formatContactDate(contact.updated_at || contact.created_at);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@ -1035,7 +1040,7 @@ function displayContacts(contacts) {
|
|||||||
<td class="text-muted col-title">${safeTitle}</td>
|
<td class="text-muted col-title">${safeTitle}</td>
|
||||||
<td class="col-companies">
|
<td class="col-companies">
|
||||||
<span class="company-count-chip" title="${companiesTitle}">
|
<span class="company-count-chip" title="${companiesTitle}">
|
||||||
<i class="bi bi-building"></i>${effectiveCompanyCount}
|
<i class="bi bi-building"></i>${companyCount}
|
||||||
</span>
|
</span>
|
||||||
${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''}
|
${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -161,6 +161,11 @@ class Settings(BaseSettings):
|
|||||||
TIMETRACKING_ROUND_INCREMENT: float = 0.5
|
TIMETRACKING_ROUND_INCREMENT: float = 0.5
|
||||||
TIMETRACKING_ROUND_METHOD: str = "up" # "up", "down", "nearest"
|
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
|
# Time Tracking Module Safety Flags
|
||||||
TIMETRACKING_VTIGER_READ_ONLY: bool = True
|
TIMETRACKING_VTIGER_READ_ONLY: bool = True
|
||||||
TIMETRACKING_VTIGER_DRY_RUN: bool = True
|
TIMETRACKING_VTIGER_DRY_RUN: bool = True
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app")
|
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", {
|
return templates.TemplateResponse("customers/frontend/customer_detail.html", {
|
||||||
"request": request,
|
"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-label">EAN-nummer</span>
|
||||||
<span class="info-value" id="ean">-</span>
|
<span class="info-value" id="ean">-</span>
|
||||||
</div>
|
</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">
|
<div class="info-row">
|
||||||
<span class="info-label">Spærret</span>
|
<span class="info-label">Spærret</span>
|
||||||
<span class="info-value" id="barred">-</span>
|
<span class="info-value" id="barred">-</span>
|
||||||
@ -1023,6 +1043,43 @@
|
|||||||
<input type="text" class="form-control" id="editCity">
|
<input type="text" class="form-control" id="editCity">
|
||||||
</div>
|
</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 -->
|
<!-- Status -->
|
||||||
<div class="col-12 mt-4">
|
<div class="col-12 mt-4">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
@ -1319,6 +1376,9 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const customerId = parseInt(window.location.pathname.split('/').pop());
|
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 customerData = null;
|
||||||
let pipelineStages = [];
|
let pipelineStages = [];
|
||||||
let allTagsCache = [];
|
let allTagsCache = [];
|
||||||
@ -1674,6 +1734,22 @@ function displayCustomer(customer) {
|
|||||||
document.getElementById('vatZone').textContent = customer.vat_zone || '-';
|
document.getElementById('vatZone').textContent = customer.vat_zone || '-';
|
||||||
document.getElementById('currency').textContent = customer.currency_code || 'DKK';
|
document.getElementById('currency').textContent = customer.currency_code || 'DKK';
|
||||||
document.getElementById('ean').textContent = customer.ean || '-';
|
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
|
document.getElementById('barred').innerHTML = customer.barred
|
||||||
? '<span class="badge bg-danger">Ja</span>'
|
? '<span class="badge bg-danger">Ja</span>'
|
||||||
: '<span class="badge bg-success">Nej</span>';
|
: '<span class="badge bg-success">Nej</span>';
|
||||||
@ -3899,6 +3975,11 @@ function editCustomer() {
|
|||||||
document.getElementById('editAddress').value = customerData.address || '';
|
document.getElementById('editAddress').value = customerData.address || '';
|
||||||
document.getElementById('editPostalCode').value = customerData.postal_code || '';
|
document.getElementById('editPostalCode').value = customerData.postal_code || '';
|
||||||
document.getElementById('editCity').value = customerData.city || '';
|
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;
|
document.getElementById('editIsActive').checked = customerData.is_active !== false;
|
||||||
|
|
||||||
// Show modal
|
// Show modal
|
||||||
@ -3907,6 +3988,11 @@ function editCustomer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveCustomerEdit() {
|
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 = {
|
const updateData = {
|
||||||
name: document.getElementById('editName').value,
|
name: document.getElementById('editName').value,
|
||||||
cvr_number: document.getElementById('editCvrNumber').value || null,
|
cvr_number: document.getElementById('editCvrNumber').value || null,
|
||||||
@ -3920,6 +4006,11 @@ async function saveCustomerEdit() {
|
|||||||
address: document.getElementById('editAddress').value || null,
|
address: document.getElementById('editAddress').value || null,
|
||||||
postal_code: document.getElementById('editPostalCode').value || null,
|
postal_code: document.getElementById('editPostalCode').value || null,
|
||||||
city: document.getElementById('editCity').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
|
is_active: document.getElementById('editIsActive').checked
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
import json
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
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)
|
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]:
|
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.
|
"""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)
|
rows_by_customer[int(resolved_customer_id)].append(row)
|
||||||
|
|
||||||
created_orders = []
|
created_drafts = []
|
||||||
failed_customers: List[Dict[str, Any]] = []
|
failed_customers: List[Dict[str, Any]] = []
|
||||||
for cust_id, cust_rows in rows_by_customer.items():
|
for cust_id, cust_rows in rows_by_customer.items():
|
||||||
try:
|
try:
|
||||||
order_id = _create_order_from_selected(cust_id, cust_rows, user_id)
|
draft_id = _create_ordre_draft_from_selected(cust_id, cust_rows, user_id)
|
||||||
created_orders.append({"customer_id": cust_id, "order_id": order_id})
|
created_drafts.append({"customer_id": cust_id, "draft_id": draft_id})
|
||||||
except HTTPException as ex:
|
except HTTPException as ex:
|
||||||
failed_customers.append(
|
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:
|
if skipped_missing_customer:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
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.
|
# Time queue must never push directly to e-conomic.
|
||||||
# Orders are created locally and can be transferred manually from Orders page.
|
# 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"
|
orders_url = "/ordre"
|
||||||
if len(order_ids) == 1:
|
if len(draft_ids) == 1:
|
||||||
orders_url = f"/ordre/{order_ids[0]}"
|
orders_url = f"/ordre/{draft_ids[0]}"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"selected": len(ids),
|
"selected": len(ids),
|
||||||
"order_candidates": len(selected_order_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,
|
"skipped_missing_customer": skipped_missing_customer,
|
||||||
"failed_customers": failed_customers,
|
"failed_customers": failed_customers,
|
||||||
"orders_url": orders_url,
|
"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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@ -455,18 +455,19 @@
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ ids }),
|
body: JSON.stringify({ ids }),
|
||||||
});
|
});
|
||||||
const orders = (result.created_orders || []).map((x) => {
|
const drafts = (result.created_drafts || result.created_orders || []).map((x) => {
|
||||||
return `customer ${x.customer_id}, order ${x.order_id}`;
|
const draftId = x.draft_id || x.order_id;
|
||||||
|
return `customer ${x.customer_id}, draft ${draftId}`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
const skipped = (result.skipped_missing_customer || []);
|
const skipped = (result.skipped_missing_customer || []);
|
||||||
const failedCustomers = (result.failed_customers || []);
|
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 nextStep = result.orders_url ? `\n\nAabn ordre: ${result.orders_url}` : '';
|
||||||
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
|
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
|
||||||
const failedMsg = failedCustomers.length
|
const failedMsg = failedCustomers.length
|
||||||
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`
|
? `\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 loadCustomers();
|
||||||
await loadEntries();
|
await loadEntries();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -35,6 +35,11 @@ class CustomerUpdate(BaseModel):
|
|||||||
mobile_phone: Optional[str] = None
|
mobile_phone: Optional[str] = None
|
||||||
invoice_email: Optional[str] = None
|
invoice_email: Optional[str] = None
|
||||||
is_active: Optional[bool] = 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):
|
class Customer(CustomerBase):
|
||||||
|
|||||||
@ -1200,42 +1200,89 @@ async def create_contact(location_id: int, data: ContactCreate):
|
|||||||
|
|
||||||
location_name = location_check[0]['name']
|
location_name = location_check[0]['name']
|
||||||
|
|
||||||
contact_name = (data.contact_name or "").strip()
|
if not data.existing_contact_id:
|
||||||
contact_email = _none_if_empty(data.contact_email)
|
raise HTTPException(
|
||||||
contact_phone = _none_if_empty(data.contact_phone)
|
status_code=400,
|
||||||
contact_role = _none_if_empty(data.role)
|
detail="Du skal vælge en eksisterende kontakt"
|
||||||
|
)
|
||||||
|
|
||||||
if data.existing_contact_id:
|
existing_contact_query = """
|
||||||
existing_contact_query = """
|
SELECT id, first_name, last_name, email, phone, mobile, title
|
||||||
SELECT id, first_name, last_name, email, phone, mobile, title
|
FROM contacts
|
||||||
FROM contacts
|
WHERE id = %s
|
||||||
WHERE id = %s
|
"""
|
||||||
|
existing_contact_result = execute_query(existing_contact_query, (data.existing_contact_id,))
|
||||||
|
|
||||||
|
if not existing_contact_result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Existing contact with id {data.existing_contact_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_contact = existing_contact_result[0]
|
||||||
|
contact_name = (
|
||||||
|
f"{(existing_contact.get('first_name') or '').strip()} "
|
||||||
|
f"{(existing_contact.get('last_name') or '').strip()}"
|
||||||
|
).strip()
|
||||||
|
contact_email = _none_if_empty(existing_contact.get('email'))
|
||||||
|
contact_phone = _none_if_empty(existing_contact.get('mobile')) or _none_if_empty(existing_contact.get('phone'))
|
||||||
|
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="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(
|
||||||
"""
|
"""
|
||||||
existing_contact_result = execute_query(existing_contact_query, (data.existing_contact_id,))
|
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"],),
|
||||||
|
)
|
||||||
|
|
||||||
if not existing_contact_result:
|
logger.info(
|
||||||
raise HTTPException(
|
"ℹ️ Existing contact relation reused for location %s and contact %s",
|
||||||
status_code=404,
|
location_id,
|
||||||
detail=f"Existing contact with id {data.existing_contact_id} not found"
|
data.existing_contact_id,
|
||||||
)
|
)
|
||||||
|
return Contact(**existing_relation_result[0])
|
||||||
existing_contact = existing_contact_result[0]
|
|
||||||
existing_full_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'))
|
|
||||||
|
|
||||||
if not contact_name:
|
|
||||||
raise HTTPException(status_code=400, detail="contact_name is required")
|
|
||||||
|
|
||||||
# If is_primary is true, unset primary flag on other contacts
|
# If is_primary is true, unset primary flag on other contacts
|
||||||
if data.is_primary:
|
if data.is_primary:
|
||||||
@ -1246,24 +1293,54 @@ async def create_contact(location_id: int, data: ContactCreate):
|
|||||||
"""
|
"""
|
||||||
execute_query(unset_primary_query, (location_id,))
|
execute_query(unset_primary_query, (location_id,))
|
||||||
|
|
||||||
# INSERT new contact
|
has_related_contact_id_column = bool(execute_query(
|
||||||
insert_query = """
|
"""
|
||||||
INSERT INTO locations_contacts (
|
SELECT 1
|
||||||
location_id, contact_name, contact_email, contact_phone,
|
FROM information_schema.columns
|
||||||
role, is_primary, created_at, updated_at
|
WHERE table_name = 'locations_contacts'
|
||||||
)
|
AND column_name = 'related_contact_id'
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, NOW(), NOW())
|
LIMIT 1
|
||||||
RETURNING *
|
"""
|
||||||
"""
|
))
|
||||||
|
|
||||||
params = (
|
# INSERT new contact
|
||||||
location_id,
|
if has_related_contact_id_column:
|
||||||
contact_name,
|
insert_query = """
|
||||||
contact_email,
|
INSERT INTO locations_contacts (
|
||||||
contact_phone,
|
location_id, related_contact_id, contact_name, contact_email, contact_phone,
|
||||||
contact_role,
|
role, is_primary, created_at
|
||||||
data.is_primary
|
)
|
||||||
)
|
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
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = (
|
||||||
|
location_id,
|
||||||
|
contact_name,
|
||||||
|
contact_email,
|
||||||
|
contact_phone,
|
||||||
|
contact_role,
|
||||||
|
data.is_primary,
|
||||||
|
)
|
||||||
|
|
||||||
result = execute_query(insert_query, params)
|
result = execute_query(insert_query, params)
|
||||||
|
|
||||||
|
|||||||
@ -412,8 +412,85 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
|||||||
(id,)
|
(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["hierarchy"] = hierarchy
|
||||||
location["children"] = children
|
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
|
# Query customers
|
||||||
customers = execute_query("""
|
customers = execute_query("""
|
||||||
|
|||||||
@ -117,14 +117,19 @@ class ContactBase(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ContactCreate(ContactBase):
|
class ContactCreate(BaseModel):
|
||||||
"""Request model for creating contact"""
|
"""Request model for linking an existing global contact to a location"""
|
||||||
is_primary: bool = Field(False, description="Set as primary contact for location")
|
existing_contact_id: int = Field(
|
||||||
existing_contact_id: Optional[int] = Field(
|
...,
|
||||||
None,
|
|
||||||
ge=1,
|
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):
|
class ContactUpdate(BaseModel):
|
||||||
@ -140,6 +145,7 @@ class Contact(ContactBase):
|
|||||||
"""Full contact response model"""
|
"""Full contact response model"""
|
||||||
id: int
|
id: int
|
||||||
location_id: int
|
location_id: int
|
||||||
|
related_contact_id: Optional[int] = None
|
||||||
is_primary: bool
|
is_primary: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,210 @@
|
|||||||
|
|
||||||
{% block title %}{{ location.name }} - BMC Hub{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="container-fluid px-4 py-4">
|
<div class="container-fluid px-4 py-4 locations-detail-page">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<nav aria-label="breadcrumb" class="mb-4">
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
@ -16,24 +218,10 @@
|
|||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
<div class="case-hero">
|
||||||
<div>
|
<div class="case-hero-identity">
|
||||||
<h1 class="h2 fw-700 mb-2">{{ location.name }}</h1>
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
{% if location.hierarchy %}
|
<span class="case-id-chip">Lokation #{{ location.id }}</span>
|
||||||
<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">
|
|
||||||
{% set type_label = {
|
{% set type_label = {
|
||||||
'kompleks': 'Kompleks',
|
'kompleks': 'Kompleks',
|
||||||
'bygning': 'Bygning',
|
'bygning': 'Bygning',
|
||||||
@ -56,41 +244,62 @@
|
|||||||
'vehicle': '#8e44ad'
|
'vehicle': '#8e44ad'
|
||||||
}.get(location.location_type, '#6c757d') %}
|
}.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 }}
|
{{ type_label }}
|
||||||
</span>
|
</span>
|
||||||
{% if location.parent_location_id and location.parent_location_name %}
|
{% if location.is_active %}
|
||||||
<span class="text-muted small">
|
<span class="case-status-chip open">
|
||||||
<i class="bi bi-diagram-3 me-1"></i>
|
<span class="case-status-dot"></span>Aktiv
|
||||||
<a href="/app/locations/{{ location.parent_location_id }}" class="text-decoration-none">
|
</span>
|
||||||
{{ location.parent_location_name }}
|
{% else %}
|
||||||
</a>
|
<span class="case-status-chip closed">
|
||||||
|
<span class="case-status-dot"></span>Inaktiv
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if location.is_active %}
|
</div>
|
||||||
<span class="badge bg-success">Aktiv</span>
|
<div class="d-flex gap-2">
|
||||||
{% else %}
|
<a href="/app/locations/{{ location.id }}/edit" class="btn btn-primary btn-sm">
|
||||||
<span class="badge bg-secondary">Inaktiv</span>
|
<i class="bi bi-pencil me-2"></i>Rediger
|
||||||
{% endif %}
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
||||||
|
<i class="bi bi-trash me-2"></i>Slet
|
||||||
|
</button>
|
||||||
|
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-2"></i>Tilbage
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<a href="/app/locations/{{ location.id }}/edit" class="btn btn-primary btn-sm">
|
<div class="case-hero-meta">
|
||||||
<i class="bi bi-pencil me-2"></i>Rediger
|
<div class="case-meta-cell">
|
||||||
</a>
|
<div class="hero-meta-label">Navn</div>
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
<div class="hero-meta-value">{{ location.name }}</div>
|
||||||
<i class="bi bi-trash me-2"></i>Slet
|
</div>
|
||||||
</button>
|
<div class="case-meta-cell">
|
||||||
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
|
<div class="hero-meta-label">Overordnet</div>
|
||||||
<i class="bi bi-arrow-left me-2"></i>Tilbage
|
{% if location.parent_location_id and location.parent_location_name %}
|
||||||
</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs Navigation -->
|
<!-- 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">
|
<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">
|
<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
|
<i class="bi bi-info-circle me-2"></i>Oplysninger
|
||||||
@ -99,6 +308,7 @@
|
|||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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
|
<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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
@ -109,11 +319,13 @@
|
|||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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
|
<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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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
|
<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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
@ -124,6 +336,7 @@
|
|||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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
|
<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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
@ -301,7 +514,13 @@
|
|||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div class="flex-grow-1">
|
<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">
|
<p class="small text-muted mb-2">
|
||||||
{% if contact.role %}{{ contact.role }}{% endif %}
|
{% if contact.role %}{{ contact.role }}{% endif %}
|
||||||
{% if contact.is_primary %}<span class="badge bg-info ms-2">Primær</span>{% endif %}
|
{% if contact.is_primary %}<span class="badge bg-info ms-2">Primær</span>{% endif %}
|
||||||
@ -663,16 +882,21 @@
|
|||||||
<form id="addContactForm">
|
<form id="addContactForm">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="contactName" class="form-label">Navn *</label>
|
<label for="existingContactSearch" class="form-label">Søg eksisterende kontakt</label>
|
||||||
<input type="text" class="form-control" id="contactName" required>
|
<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>
|
||||||
<div class="mb-3">
|
<div id="selectedExistingContact" class="alert alert-info py-2 px-3 d-none" role="alert">
|
||||||
<label for="contactEmail" class="form-label">Email</label>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<input type="email" class="form-control" id="contactEmail">
|
<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>
|
</div>
|
||||||
|
<input type="hidden" id="existingContactId" value="">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="contactPhone" class="form-label">Telefon</label>
|
<label class="form-label">Kontaktoplysninger</label>
|
||||||
<input type="tel" class="form-control" id="contactPhone">
|
<div class="form-control-plaintext small text-muted" id="selectedContactMeta">Vælg en kontakt for at se email og telefon.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="contactRole" class="form-label">Rolle</label>
|
<label for="contactRole" class="form-label">Rolle</label>
|
||||||
@ -781,6 +1005,129 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||||
const locationId = '{{ location.id }}';
|
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
|
// Delete location
|
||||||
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
|
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
|
||||||
@ -803,14 +1150,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add contact form
|
// Add contact form
|
||||||
document.getElementById('addContactForm').addEventListener('submit', function(e) {
|
addContactForm.addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
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 = {
|
const contactData = {
|
||||||
location_id: locationId,
|
location_id: locationId,
|
||||||
contact_name: document.getElementById('contactName').value,
|
role: contactRoleInput.value,
|
||||||
contact_email: document.getElementById('contactEmail').value,
|
existing_contact_id: existingContactIdInput.value ? parseInt(existingContactIdInput.value, 10) : null,
|
||||||
contact_phone: document.getElementById('contactPhone').value,
|
|
||||||
role: document.getElementById('contactRole').value,
|
|
||||||
is_primary: document.getElementById('isPrimaryContact').checked
|
is_primary: document.getElementById('isPrimaryContact').checked
|
||||||
};
|
};
|
||||||
fetch(`/api/v1/locations/${locationId}/contacts`, {
|
fetch(`/api/v1/locations/${locationId}/contacts`, {
|
||||||
@ -818,13 +1177,35 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(contactData)
|
body: JSON.stringify(contactData)
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(async response => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
bootstrap.Modal.getInstance(document.getElementById('addContactModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('addContactModal')).hide();
|
||||||
setTimeout(() => location.reload(), 300);
|
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
|
// Add service form
|
||||||
|
|||||||
@ -2,8 +2,91 @@
|
|||||||
|
|
||||||
{% block title %}Lokaliteter - BMC Hub{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="container-fluid px-4 py-4">
|
<div class="container-fluid px-4 py-4 locations-list-page">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<nav aria-label="breadcrumb" class="mb-4">
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
@ -15,8 +98,41 @@
|
|||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<h1 class="h2 fw-700 mb-2">Lokaliteter</h1>
|
<div class="locations-hero p-3 p-lg-4">
|
||||||
<p class="text-muted small">Oversigt over alle lokationer og faciliteter</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -294,12 +410,32 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const searchInput = document.getElementById('locationSearch');
|
const searchInput = document.getElementById('locationSearch');
|
||||||
const visibleCount = document.getElementById('visibleCount');
|
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 rows = Array.from(document.querySelectorAll('.location-row'));
|
||||||
|
|
||||||
const rowById = new Map();
|
const rowById = new Map();
|
||||||
const parentById = new Map();
|
const parentById = new Map();
|
||||||
const childrenById = 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 => {
|
rows.forEach(row => {
|
||||||
const id = row.getAttribute('data-location-id');
|
const id = row.getAttribute('data-location-id');
|
||||||
const parentId = row.getAttribute('data-parent-id') || null;
|
const parentId = row.getAttribute('data-parent-id') || null;
|
||||||
@ -374,6 +510,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
collapseAll();
|
collapseAll();
|
||||||
|
updateSummaryStats();
|
||||||
|
|
||||||
function toggleNode(targetId) {
|
function toggleNode(targetId) {
|
||||||
const row = rowById.get(targetId);
|
const row = rowById.get(targetId);
|
||||||
@ -442,6 +579,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (visibleCount) {
|
if (visibleCount) {
|
||||||
visibleCount.textContent = String(visible);
|
visibleCount.textContent = String(visible);
|
||||||
}
|
}
|
||||||
|
if (statVisible) {
|
||||||
|
statVisible.textContent = String(visible);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
@ -450,6 +590,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(applySearchFilter, 150);
|
debounceTimer = setTimeout(applySearchFilter, 150);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === '/' && document.activeElement !== searchInput) {
|
||||||
|
e.preventDefault();
|
||||||
|
searchInput.focus();
|
||||||
|
searchInput.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select all checkbox
|
// Select all checkbox
|
||||||
|
|||||||
@ -18,7 +18,7 @@ ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"}
|
|||||||
class OrdreLineInput(BaseModel):
|
class OrdreLineInput(BaseModel):
|
||||||
line_key: str
|
line_key: str
|
||||||
source_type: str
|
source_type: str
|
||||||
source_id: int
|
source_id: Optional[int] = None
|
||||||
description: str
|
description: str
|
||||||
quantity: float = Field(gt=0)
|
quantity: float = Field(gt=0)
|
||||||
unit_price: float = Field(ge=0)
|
unit_price: float = Field(ge=0)
|
||||||
@ -45,6 +45,10 @@ class OrdreDraftUpsertRequest(BaseModel):
|
|||||||
layout_number: Optional[int] = None
|
layout_number: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrdreDraftConsolidateRequest(BaseModel):
|
||||||
|
draft_ids: List[int] = Field(..., min_length=2)
|
||||||
|
|
||||||
|
|
||||||
def _safe_json_field(value: Any) -> Any:
|
def _safe_json_field(value: Any) -> Any:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@ -572,3 +576,114 @@ async def delete_ordre_draft(draft_id: int, http_request: Request):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ Error deleting ordre draft: %s", e, exc_info=True)
|
logger.error("❌ Error deleting ordre draft: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="Failed to delete ordre draft")
|
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));
|
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) {
|
function sourceBadge(type) {
|
||||||
if (type === 'subscription') return '<span class="badge bg-primary line-source">Abonnement</span>';
|
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>';
|
if (type === 'hardware') return '<span class="badge bg-secondary line-source">Hardware</span>';
|
||||||
@ -335,9 +344,20 @@
|
|||||||
const index = line.originalIndex;
|
const index = line.originalIndex;
|
||||||
const isManual = line.source_type === 'manual';
|
const isManual = line.source_type === 'manual';
|
||||||
const descriptionField = isManual
|
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;">`
|
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 += `
|
html += `
|
||||||
<tr class="order-lines-container" data-order="order-${groupIndex}">
|
<tr class="order-lines-container" data-order="order-${groupIndex}">
|
||||||
@ -361,9 +381,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
|
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
|
||||||
<td>${renderExportStatusBadge(line)}</td>
|
<td>${renderExportStatusBadge(line)}</td>
|
||||||
<td>
|
<td>${manualActions}</td>
|
||||||
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
@ -415,6 +433,62 @@
|
|||||||
return '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
|
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) {
|
function selectCustomer(customer) {
|
||||||
document.getElementById('customerId').value = customer.id;
|
document.getElementById('customerId').value = customer.id;
|
||||||
document.getElementById('customerSearch').value = customer.name || '';
|
document.getElementById('customerSearch').value = customer.name || '';
|
||||||
|
|||||||
@ -377,11 +377,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = orderLines.map((line, index) => {
|
tbody.innerHTML = orderLines.map((line, index) => {
|
||||||
const isManual = line.source_type === 'manual';
|
const isExportedLine = line.export_status === 'exported';
|
||||||
const descriptionField = isManual
|
const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
|
||||||
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}"
|
${isExportedLine ? 'disabled' : ''}
|
||||||
onchange="orderLines[${index}].description = this.value;">`
|
onchange="orderLines[${index}].description = this.value;">`;
|
||||||
: (line.description || '-');
|
|
||||||
|
|
||||||
const exportStatus = line.export_status || '-';
|
const exportStatus = line.export_status || '-';
|
||||||
const statusBadge = exportStatus === 'exported'
|
const statusBadge = exportStatus === 'exported'
|
||||||
@ -390,28 +389,40 @@
|
|||||||
? '<span class="badge bg-warning text-dark">Dry-run</span>'
|
? '<span class="badge bg-warning text-dark">Dry-run</span>'
|
||||||
: '<span class="badge bg-light text-dark border">Ikke eksporteret</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 `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${sourceBadge(line.source_type)}</td>
|
<td>${sourceBadge(line.source_type)}</td>
|
||||||
<td>${descriptionField}</td>
|
<td>${descriptionField}</td>
|
||||||
<td style="min-width:100px;">
|
<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)}"
|
<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});">
|
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
|
||||||
</td>
|
</td>
|
||||||
<td style="min-width:120px;">
|
<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)}"
|
<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});">
|
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
|
||||||
</td>
|
</td>
|
||||||
<td style="min-width:110px;">
|
<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)}"
|
<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});">
|
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
|
||||||
</td>
|
</td>
|
||||||
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
|
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
|
||||||
<td>${line.unit || 'stk'}</td>
|
<td>${line.unit || 'stk'}</td>
|
||||||
<td>${statusBadge}</td>
|
<td>${statusBadge}</td>
|
||||||
<td>
|
<td>${lineActions}</td>
|
||||||
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@ -461,6 +472,61 @@
|
|||||||
renderLines();
|
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) {
|
function normalizeOrderLine(line) {
|
||||||
// Handle e-conomic format (product.description, unitNetPrice, etc.)
|
// Handle e-conomic format (product.description, unitNetPrice, etc.)
|
||||||
if (line.product && line.product.description && !line.description) {
|
if (line.product && line.product.description && !line.description) {
|
||||||
|
|||||||
@ -82,6 +82,7 @@
|
|||||||
<option value="paid">paid</option>
|
<option value="paid">paid</option>
|
||||||
</select>
|
</select>
|
||||||
<span id="selectedCountBadge" class="selected-counter">Valgte: 0</span>
|
<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-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>
|
<button class="btn btn-outline-primary" onclick="loadOrders()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
|
||||||
</div>
|
</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) {
|
async function saveQuickSyncStatus(orderId) {
|
||||||
const select = document.getElementById(`syncStatus-${orderId}`);
|
const select = document.getElementById(`syncStatus-${orderId}`);
|
||||||
const syncStatus = (select?.value || '').trim().toLowerCase();
|
const syncStatus = (select?.value || '').trim().toLowerCase();
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import logging
|
|||||||
import base64
|
import base64
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.error import URLError, HTTPError
|
from urllib.error import URLError, HTTPError
|
||||||
from urllib.request import Request as UrlRequest, urlopen
|
from urllib.request import Request as UrlRequest, urlopen
|
||||||
@ -210,7 +210,7 @@ async def yealink_established(
|
|||||||
|
|
||||||
def _is_external_number(value: Optional[str]) -> bool:
|
def _is_external_number(value: Optional[str]) -> bool:
|
||||||
d = digits_only(value)
|
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:
|
def _is_internal_number(value: Optional[str], local_ext: Optional[str]) -> bool:
|
||||||
d = digits_only(value)
|
d = digits_only(value)
|
||||||
@ -221,21 +221,6 @@ async def yealink_established(
|
|||||||
return True
|
return True
|
||||||
return len(d) <= 6
|
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)
|
local_value = _sanitize(local) or _sanitize(active_user)
|
||||||
caller_value = _sanitize(caller) or _sanitize(remote)
|
caller_value = _sanitize(caller) or _sanitize(remote)
|
||||||
callee_value = _sanitize(callee)
|
callee_value = _sanitize(callee)
|
||||||
@ -243,12 +228,6 @@ async def yealink_established(
|
|||||||
|
|
||||||
local_extension = extract_extension(local_value) or local_value
|
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
|
is_outbound = False
|
||||||
if called_number_value and _is_external_number(called_number_value):
|
if called_number_value and _is_external_number(called_number_value):
|
||||||
is_outbound = True
|
is_outbound = True
|
||||||
@ -674,29 +653,15 @@ async def list_calls(
|
|||||||
where = []
|
where = []
|
||||||
params = []
|
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:
|
if user_id is not None:
|
||||||
where.append("t.bruger_id = %s")
|
where.append("t.bruger_id = %s")
|
||||||
params.append(user_id)
|
params.append(user_id)
|
||||||
if parsed_date_from is not None:
|
if date_from:
|
||||||
where.append("t.started_at >= %s")
|
where.append("t.started_at >= %s")
|
||||||
params.append(parsed_date_from)
|
params.append(date_from)
|
||||||
if parsed_date_to is not None:
|
if date_to:
|
||||||
where.append("t.started_at < %s")
|
where.append("t.started_at <= %s")
|
||||||
params.append(parsed_date_to)
|
params.append(date_to)
|
||||||
if without_case:
|
if without_case:
|
||||||
where.append("t.sag_id IS NULL")
|
where.append("t.sag_id IS NULL")
|
||||||
|
|
||||||
|
|||||||
@ -58,42 +58,8 @@
|
|||||||
<th class="text-end">Varighed</th>
|
<th class="text-end">Varighed</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="telefoniRows" data-initial-count="{{ initial_calls|length if initial_calls else 0 }}">
|
<tbody id="telefoniRows">
|
||||||
{% if initial_calls and initial_calls|length > 0 %}
|
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
|
||||||
{% 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 %}
|
|
||||||
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -212,9 +178,6 @@ function escapeHtml(str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let telefoniCurrentUserId = null;
|
let telefoniCurrentUserId = null;
|
||||||
let telefoniAutoResetTried = false;
|
|
||||||
let telefoniFirstApiLoadDone = false;
|
|
||||||
let telefoniFiltersArmed = false;
|
|
||||||
const telefoniCallMap = new Map();
|
const telefoniCallMap = new Map();
|
||||||
const linkSagState = {
|
const linkSagState = {
|
||||||
callId: null,
|
callId: null,
|
||||||
@ -349,34 +312,20 @@ function renderLinkContactResults(results) {
|
|||||||
async function searchContacts(query) {
|
async function searchContacts(query) {
|
||||||
const token = ++linkContactState.searchToken;
|
const token = ++linkContactState.searchToken;
|
||||||
const container = document.getElementById('linkContactResults');
|
const container = document.getElementById('linkContactResults');
|
||||||
const trimmed = String(query || '').trim();
|
|
||||||
|
|
||||||
if (trimmed.length < 2) {
|
|
||||||
renderLinkContactResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (container) {
|
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>';
|
container.innerHTML = '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const qs = new URLSearchParams({ q: trimmed });
|
const qs = new URLSearchParams({ search: query || '', limit: '30', offset: '0' });
|
||||||
let res = await fetch(`/api/v1/search/contacts?${qs.toString()}`, { credentials: 'include' });
|
const res = await fetch(`/api/v1/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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token !== linkContactState.searchToken) return;
|
if (token !== linkContactState.searchToken) return;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge kontakter</div>';
|
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge kontakter</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
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);
|
renderLinkContactResults(results);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (token !== linkContactState.searchToken) return;
|
if (token !== linkContactState.searchToken) return;
|
||||||
@ -644,47 +593,6 @@ function openLinkContactModal(callId, mode = 'contact') {
|
|||||||
}, 200);
|
}, 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) {
|
async function unlinkContact(callId) {
|
||||||
if (!confirm('Fjern link til kontakt for dette opkald?')) return;
|
if (!confirm('Fjern link til kontakt for dette opkald?')) return;
|
||||||
try {
|
try {
|
||||||
@ -914,32 +822,12 @@ async function loadUsers() {
|
|||||||
|
|
||||||
async function loadCalls() {
|
async function loadCalls() {
|
||||||
const tbody = document.getElementById('telefoniRows');
|
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>';
|
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 userId = document.getElementById('filterUser').value;
|
||||||
const fromEl = document.getElementById('filterFrom');
|
const from = document.getElementById('filterFrom').value;
|
||||||
const toEl = document.getElementById('filterTo');
|
const to = document.getElementById('filterTo').value;
|
||||||
const withoutCaseEl = document.getElementById('filterWithoutCase');
|
const withoutCase = document.getElementById('filterWithoutCase').checked;
|
||||||
|
|
||||||
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 qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (userId) qs.set('user_id', userId);
|
if (userId) qs.set('user_id', userId);
|
||||||
@ -958,34 +846,10 @@ async function loadCalls() {
|
|||||||
telefoniCallMap.clear();
|
telefoniCallMap.clear();
|
||||||
(rows || []).forEach(r => telefoniCallMap.set(Number(r.id), r));
|
(rows || []).forEach(r => telefoniCallMap.set(Number(r.id), r));
|
||||||
if (!rows || rows.length === 0) {
|
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>';
|
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small">Ingen opkald fundet</td></tr>';
|
||||||
telefoniFirstApiLoadDone = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
telefoniAutoResetTried = false;
|
|
||||||
telefoniFirstApiLoadDone = true;
|
|
||||||
if (!telefoniFiltersArmed) {
|
|
||||||
telefoniFiltersArmed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = rows.map(r => {
|
tbody.innerHTML = rows.map(r => {
|
||||||
const started = r.started_at ? new Date(r.started_at) : null;
|
const started = r.started_at ? new Date(r.started_at) : null;
|
||||||
const dateTxt = started ? started.toLocaleString('da-DK') : '-';
|
const dateTxt = started ? started.toLocaleString('da-DK') : '-';
|
||||||
@ -1002,25 +866,13 @@ async function loadCalls() {
|
|||||||
const contactHtml = r.kontakt_id
|
const contactHtml = r.kontakt_id
|
||||||
? `<div class="d-flex align-items-center gap-2 flex-wrap">
|
? `<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>
|
<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">
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${Number(r.id)})">Skift</button>
|
||||||
<i class="bi bi-pencil-square"></i>
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact(${Number(r.id)})">Fjern</button>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
${r.contact_company ? `<div class="text-muted small">${escapeHtml(r.contact_company)}</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-secondary" onclick="openLinkContactModal(${Number(r.id)})" title="Vælg kontakt/firma">
|
||||||
<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-three-dots"></i>
|
||||||
<i class="bi bi-person-plus"></i>
|
</button>`;
|
||||||
</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>`;
|
|
||||||
|
|
||||||
const numberForTitle = (r.display_number || r.ekstern_nummer || '').trim();
|
const numberForTitle = (r.display_number || r.ekstern_nummer || '').trim();
|
||||||
const createQs = new URLSearchParams();
|
const createQs = new URLSearchParams();
|
||||||
@ -1113,38 +965,22 @@ async function unlinkCase(callId) {
|
|||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
initLinkContactModalEvents();
|
initLinkContactModalEvents();
|
||||||
initLinkSagModalEvents();
|
initLinkSagModalEvents();
|
||||||
|
|
||||||
const userFilter = document.getElementById('filterUser');
|
const userFilter = document.getElementById('filterUser');
|
||||||
const fromFilter = document.getElementById('filterFrom');
|
const fromFilter = document.getElementById('filterFrom');
|
||||||
const toFilter = document.getElementById('filterTo');
|
const toFilter = document.getElementById('filterTo');
|
||||||
const withoutCaseFilter = document.getElementById('filterWithoutCase');
|
const withoutCaseFilter = document.getElementById('filterWithoutCase');
|
||||||
const tbody = document.getElementById('telefoniRows');
|
|
||||||
const ssrCount = Number(tbody?.dataset?.initialCount || '0');
|
|
||||||
|
|
||||||
if (userFilter) userFilter.value = '';
|
if (userFilter) userFilter.value = '';
|
||||||
if (fromFilter) fromFilter.value = '';
|
if (fromFilter) fromFilter.value = '';
|
||||||
if (toFilter) toFilter.value = '';
|
if (toFilter) toFilter.value = '';
|
||||||
if (withoutCaseFilter) withoutCaseFilter.checked = false;
|
if (withoutCaseFilter) withoutCaseFilter.checked = false;
|
||||||
telefoniAutoResetTried = false;
|
|
||||||
// Filters are already cleared above so we can arm immediately.
|
|
||||||
telefoniFiltersArmed = true;
|
|
||||||
|
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
|
document.getElementById('btnRefresh').addEventListener('click', loadCalls);
|
||||||
document.getElementById('btnRefresh').addEventListener('click', () => loadCalls());
|
document.getElementById('filterUser').addEventListener('change', loadCalls);
|
||||||
document.getElementById('filterUser').addEventListener('change', () => loadCalls());
|
document.getElementById('filterFrom').addEventListener('change', loadCalls);
|
||||||
document.getElementById('filterFrom').addEventListener('change', () => loadCalls());
|
document.getElementById('filterTo').addEventListener('change', loadCalls);
|
||||||
document.getElementById('filterTo').addEventListener('change', () => loadCalls());
|
document.getElementById('filterWithoutCase').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.
|
|
||||||
await loadCalls();
|
await loadCalls();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -235,6 +235,178 @@ def _score_apigw_product(product: Dict[str, Any], normalized_query: str, tokens:
|
|||||||
return score
|
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])
|
@router.get("/products/apigateway/search", response_model=Dict[str, Any])
|
||||||
async def search_apigw_products(
|
async def search_apigw_products(
|
||||||
q: Optional[str] = Query(None),
|
q: Optional[str] = Query(None),
|
||||||
@ -310,75 +482,93 @@ async def search_apigw_products(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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])
|
@router.post("/products/apigateway/import", response_model=Dict[str, Any])
|
||||||
async def import_apigw_product(payload: Dict[str, Any]):
|
async def import_apigw_product(payload: Dict[str, Any]):
|
||||||
"""Import a single APIGW product into local catalog."""
|
"""Import a single APIGW product into local catalog."""
|
||||||
try:
|
try:
|
||||||
product = payload.get("product") or payload
|
return _import_apigw_product_to_local(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
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -210,7 +210,7 @@
|
|||||||
<div class="products-search">
|
<div class="products-search">
|
||||||
<div class="input-group" style="min-width: 260px;">
|
<div class="input-group" style="min-width: 260px;">
|
||||||
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
|
<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>
|
</div>
|
||||||
<select class="form-select" id="localSearchStatus" style="max-width: 140px;">
|
<select class="form-select" id="localSearchStatus" style="max-width: 140px;">
|
||||||
<option value="active" selected>Aktiv</option>
|
<option value="active" selected>Aktiv</option>
|
||||||
@ -234,6 +234,9 @@
|
|||||||
<button class="btn btn-outline-primary" onclick="applyLocalProductSearch()">
|
<button class="btn btn-outline-primary" onclick="applyLocalProductSearch()">
|
||||||
<i class="bi bi-search"></i> Soeg
|
<i class="bi bi-search"></i> Soeg
|
||||||
</button>
|
</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()">
|
<button class="btn btn-outline-secondary" onclick="clearLocalProductSearch()">
|
||||||
<i class="bi bi-x"></i> Nulstil
|
<i class="bi bi-x"></i> Nulstil
|
||||||
</button>
|
</button>
|
||||||
@ -593,6 +596,67 @@ function applyLocalProductSearch() {
|
|||||||
loadProducts();
|
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() {
|
function clearLocalProductSearch() {
|
||||||
const fields = [
|
const fields = [
|
||||||
'localSearchQuery',
|
'localSearchQuery',
|
||||||
@ -654,7 +718,11 @@ function setupLocalSearchEvents() {
|
|||||||
el.addEventListener('keydown', (event) => {
|
el.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
applyLocalProductSearch();
|
if (id === 'localSearchQuery' && (event.ctrlKey || event.metaKey)) {
|
||||||
|
searchAndCreateByGatewayCode();
|
||||||
|
} else {
|
||||||
|
applyLocalProductSearch();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
el.addEventListener('input', triggerSearch);
|
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/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="/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="/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="/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="/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>
|
<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
|
<i class="bi bi-currency-dollar me-2"></i>Økonomi
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu mt-2">
|
<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="#">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="/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>
|
<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"><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/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/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/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/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>
|
<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)">
|
<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>
|
<i class="bi bi-plus-circle-fill fs-5"></i>
|
||||||
</button>
|
</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">
|
<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>
|
<i class="bi bi-question-circle"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -1266,14 +1260,12 @@ window.addEventListener('unhandledrejection', function(event) {
|
|||||||
});
|
});
|
||||||
</script>
|
</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/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/tag-picker.js?v=2.2"></script>
|
||||||
<script src="/static/js/task-template-selector.js?v=1.1"></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/notifications.js?v=1.0"></script>
|
||||||
<script src="/static/js/telefoni.js?v=2.2"></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/sms.js?v=1.0"></script>
|
||||||
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
||||||
<script src="/static/js/bug-report.js?v=1.6"></script>
|
|
||||||
<script>
|
<script>
|
||||||
// Dark Mode Toggle Logic
|
// Dark Mode Toggle Logic
|
||||||
window.BMC_CAN_CLICK_TO_CALL = {{ 'true' if _can_click_to_call else 'false' }};
|
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) -->
|
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
|
||||||
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
|
{% 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 -->
|
<!-- Manual Help Modal -->
|
||||||
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}
|
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}
|
||||||
|
|
||||||
|
|||||||
@ -33,8 +33,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 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>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
|
<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>
|
<button class="btn btn-outline-secondary btn-sm" onclick="collapseAll()">Fold alt sammen</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<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()">
|
<button class="btn btn-danger" onclick="rejectSelected()">
|
||||||
<i class="bi bi-x-circle"></i> Afvis Valgte
|
<i class="bi bi-x-circle"></i> Afvis Valgte
|
||||||
</button>
|
</button>
|
||||||
@ -599,11 +602,20 @@
|
|||||||
function updateSummary() {
|
function updateSummary() {
|
||||||
const name = currentCustomerData?.customer_name || 'Kunde';
|
const name = currentCustomerData?.customer_name || 'Kunde';
|
||||||
const rate = currentCustomerData?.customer_rate || DEFAULT_RATE;
|
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('customer-name').textContent = name;
|
||||||
document.getElementById('hourly-rate').textContent = parseFloat(rate).toFixed(2);
|
document.getElementById('hourly-rate').textContent = parseFloat(rate).toFixed(2);
|
||||||
document.getElementById('pending-count').textContent = pendingEntries.length;
|
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;
|
let totalValue = 0;
|
||||||
pendingEntries.forEach(entry => {
|
pendingEntries.forEach(entry => {
|
||||||
const hoursInput = document.getElementById(`hours-${entry.id}`);
|
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() {
|
async function rejectSelected() {
|
||||||
const ids = Array.from(selectedEntries);
|
const ids = Array.from(selectedEntries);
|
||||||
if (ids.length === 0) return;
|
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.bottom_bar.backend import public_router as bottom_bar_public_api
|
||||||
from app.modules.rentals.backend import router as rentals_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.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
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -155,34 +152,6 @@ logging.basicConfig(
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Lifecycle management - startup and shutdown"""
|
"""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(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(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(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:
|
if settings.LINKS_MODULE_ENABLED:
|
||||||
from app.modules.links.backend import router as links_api
|
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(fedex_views.router, tags=["Frontend"])
|
||||||
app.include_router(anydesk_views.router, tags=["Frontend"])
|
app.include_router(anydesk_views.router, tags=["Frontend"])
|
||||||
app.include_router(manual_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:
|
if settings.LINKS_MODULE_ENABLED:
|
||||||
from app.modules.links.frontend import views as links_views
|
from app.modules.links.frontend import views as links_views
|
||||||
@ -546,20 +512,8 @@ async def health_check():
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from copy import deepcopy
|
|
||||||
from uvicorn.config import LOGGING_CONFIG as UVICORN_LOGGING_CONFIG
|
|
||||||
import os
|
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
|
# Only enable reload in local development (not in Docker) - check both variables
|
||||||
enable_reload = (
|
enable_reload = (
|
||||||
os.getenv("ENABLE_RELOAD", "false").lower() == "true" or
|
os.getenv("ENABLE_RELOAD", "false").lower() == "true" or
|
||||||
@ -575,7 +529,6 @@ if __name__ == "__main__":
|
|||||||
reload_includes=["*.py"],
|
reload_includes=["*.py"],
|
||||||
reload_dirs=["app"],
|
reload_dirs=["app"],
|
||||||
reload_excludes=[".git/*", "*.pyc", "__pycache__/*", "logs/*", "uploads/*", "data/*"],
|
reload_excludes=[".git/*", "*.pyc", "__pycache__/*", "logs/*", "uploads/*", "data/*"],
|
||||||
log_config=log_config,
|
|
||||||
log_level="info"
|
log_level="info"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -587,6 +540,5 @@ if __name__ == "__main__":
|
|||||||
workers=2,
|
workers=2,
|
||||||
timeout_keep_alive=65,
|
timeout_keep_alive=65,
|
||||||
access_log=True,
|
access_log=True,
|
||||||
log_config=log_config,
|
|
||||||
log_level="info"
|
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 bugModal = null;
|
||||||
let screenshotDataUrl = null;
|
let screenshotDataUrl = null;
|
||||||
let pendingScreenshotPromise = null;
|
let pendingScreenshotPromise = null;
|
||||||
|
let isCapturingDisplayMedia = false;
|
||||||
|
|
||||||
const pushLog = (type, args) => {
|
const pushLog = (type, args) => {
|
||||||
try {
|
try {
|
||||||
@ -206,6 +207,9 @@
|
|||||||
});
|
});
|
||||||
} catch (e1) {
|
} catch (e1) {
|
||||||
console.warn('Bug report screenshot strategy 1 failed', 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)
|
// Strategy 2: Main content only (explicit selectors avoid navbar-only captures)
|
||||||
@ -227,6 +231,9 @@
|
|||||||
});
|
});
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
console.warn('Bug report screenshot strategy 2 failed', 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');
|
throw new Error('Automatic screenshot failed');
|
||||||
@ -296,7 +303,7 @@
|
|||||||
setStatus('Screenshot klar. Udfyld felterne og send.');
|
setStatus('Screenshot klar. Udfyld felterne og send.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Bug report screenshot failed', 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 {
|
} finally {
|
||||||
pendingScreenshotPromise = null;
|
pendingScreenshotPromise = null;
|
||||||
}
|
}
|
||||||
@ -305,17 +312,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function prepareScreenshotFromTrigger() {
|
function prepareScreenshotFromTrigger() {
|
||||||
pendingScreenshotPromise = (async () => {
|
pendingScreenshotPromise = takeScreenshot();
|
||||||
try {
|
|
||||||
return await takeScreenshot();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Bug report html2canvas capture failed, trying display media', e);
|
|
||||||
return await takeScreenshotViaDisplayMedia();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return pendingScreenshotPromise;
|
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() {
|
async function submitBugReport() {
|
||||||
const actual = (document.getElementById('bugActualInput')?.value || '').trim();
|
const actual = (document.getElementById('bugActualInput')?.value || '').trim();
|
||||||
const expected = (document.getElementById('bugExpectedInput')?.value || '').trim();
|
const expected = (document.getElementById('bugExpectedInput')?.value || '').trim();
|
||||||
@ -401,6 +430,7 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const btn = document.getElementById('bugReportBtn');
|
const btn = document.getElementById('bugReportBtn');
|
||||||
const submitBtn = document.getElementById('bugReportSubmitBtn');
|
const submitBtn = document.getElementById('bugReportSubmitBtn');
|
||||||
|
const displayMediaBtn = document.getElementById('bugCaptureDisplayMediaBtn');
|
||||||
const modalEl = document.getElementById('bugReportModal');
|
const modalEl = document.getElementById('bugReportModal');
|
||||||
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
@ -419,6 +449,13 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (displayMediaBtn) {
|
||||||
|
displayMediaBtn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
captureScreenshotViaDisplayMediaFromUserGesture();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (modalEl) {
|
if (modalEl) {
|
||||||
modalEl.addEventListener('hidden.bs.modal', () => {
|
modalEl.addEventListener('hidden.bs.modal', () => {
|
||||||
const actual = document.getElementById('bugActualInput');
|
const actual = document.getElementById('bugActualInput');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user