diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html
index 5842d92..8775ec0 100644
--- a/app/customers/frontend/customer_detail.html
+++ b/app/customers/frontend/customer_detail.html
@@ -1043,18 +1043,6 @@
Standard timepris
-
-
- Særlig fragtpris
- -
-
-
- Leverandørservice
- -
-
-
- Faktureringsgebyr
- -
-
Spærret
-
@@ -1983,25 +1971,6 @@
-
-
-
-
-
-
-
-
-
Sæt 0 for at slå gebyr fra på ordren.
-
-
-
-
-
-
-
-
@@ -2754,21 +2723,10 @@ function displayCustomer(customer) {
document.getElementById('currency').textContent = customer.currency_code || 'DKK';
document.getElementById('ean').textContent = customer.ean || '-';
const standardMargin = customer.standard_margin_percent ?? customerDefaultMarginPercent;
- const invoiceFee = customer.invoice_fee_amount ?? customerDefaultInvoiceFee;
const standardHourlyRate = customer.standard_hourly_rate ?? customerDefaultHourlyRate;
- const freight = customer.special_freight_price;
document.getElementById('standardMarginPercent').textContent = `${Number(standardMargin).toFixed(2)} %`;
document.getElementById('standardHourlyRate').textContent = `${Number(standardHourlyRate).toFixed(2)} DKK`;
- document.getElementById('specialFreightPrice').textContent = (freight === null || typeof freight === 'undefined')
- ? '-'
- : `${Number(freight).toFixed(2)} DKK`;
- document.getElementById('supplierServiceEnrolled').innerHTML = customer.supplier_service_enrolled
- ? 'Tilmeldt'
- : 'Ikke tilmeldt';
- document.getElementById('invoiceFeeAmount').textContent = Number(invoiceFee) === 0
- ? '0,00 DKK (deaktiveret)'
- : `${Number(invoiceFee).toFixed(2)} DKK`;
document.getElementById('barred').innerHTML = customer.barred
? 'Ja'
: 'Nej';
@@ -5560,9 +5518,6 @@ function editCustomer() {
document.getElementById('editCity').value = customerData.city || '';
document.getElementById('editStandardMarginPercent').value = (customerData.standard_margin_percent ?? customerDefaultMarginPercent);
document.getElementById('editStandardHourlyRate').value = (customerData.standard_hourly_rate ?? customerDefaultHourlyRate);
- document.getElementById('editSpecialFreightPrice').value = customerData.special_freight_price ?? '';
- document.getElementById('editInvoiceFeeAmount').value = (customerData.invoice_fee_amount ?? customerDefaultInvoiceFee);
- document.getElementById('editSupplierServiceEnrolled').checked = !!customerData.supplier_service_enrolled;
document.getElementById('editIsActive').checked = customerData.is_active !== false;
// Show modal
@@ -5573,9 +5528,6 @@ function editCustomer() {
async function saveCustomerEdit() {
const marginValue = document.getElementById('editStandardMarginPercent').value;
const hourlyRateValue = document.getElementById('editStandardHourlyRate').value;
- const freightValue = document.getElementById('editSpecialFreightPrice').value;
- const invoiceFeeValue = document.getElementById('editInvoiceFeeAmount').value;
-
const updateData = {
name: document.getElementById('editName').value,
cvr_number: document.getElementById('editCvrNumber').value || null,
@@ -5591,9 +5543,6 @@ async function saveCustomerEdit() {
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
};
diff --git a/app/economy/frontend/also_cloud.html b/app/economy/frontend/also_cloud.html
index 2c31da0..f6e992e 100644
--- a/app/economy/frontend/also_cloud.html
+++ b/app/economy/frontend/also_cloud.html
@@ -795,6 +795,11 @@
+ ${!line.matched_product_id ? `
+
+ ` : ''}
${state.activeProductMapLineId === Number(line.id) ? renderProductMapEditor(line) : ''}
` : ''}
@@ -908,6 +913,24 @@
}
};
+ const createProductForLine = async (lineId) => {
+ const line = (state.selectedJobLines || []).find(item => Number(item.id) === Number(lineId));
+ const productName = line?.product_name || 'dette produkt';
+ if (!window.confirm(`Opret ${productName} som produkt i Hub og map ALSO-varen automatisk?`)) return;
+
+ el('uploadResult').textContent = `Opretter Hub-produkt for linje #${lineId}...`;
+ const result = await fetchJson(`/api/v1/also/import-lines/${lineId}/create-product`, {
+ method: 'POST',
+ });
+ el('uploadResult').textContent = [
+ `${result.product_created ? 'Hub-produkt oprettet og mappet' : 'Eksisterende Hub-produkt mappet'}: ${result.product_name} (#${result.product_id})`,
+ `Berørte linjer i job: ${formatNumber(result.affected_line_count || 0)}`,
+ `Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`,
+ ].join('\n');
+ await refreshAll();
+ if (state.selectedJobId) await loadJobDetails(state.selectedJobId);
+ };
+
const autoMapSelectedJob = async () => {
if (!state.selectedJobId) return;
el('uploadResult').textContent = `Kører auto-map for job #${state.selectedJobId}...`;
@@ -1140,6 +1163,25 @@
return;
}
+ const createProductButton = event.target.closest('.js-product-create');
+ if (createProductButton) {
+ const lineId = Number(createProductButton.getAttribute('data-line-id'));
+ const originalHtml = createProductButton.innerHTML;
+ createProductButton.disabled = true;
+ createProductButton.innerHTML = 'Opretter...';
+ try {
+ await createProductForLine(lineId);
+ } catch (err) {
+ console.error(err);
+ el('uploadResult').textContent = `Produktoprettelse fejlede:\n${err.message}`;
+ alert(`Kunne ikke oprette produktet.\n\n${err.message}`);
+ } finally {
+ createProductButton.disabled = false;
+ createProductButton.innerHTML = originalHtml;
+ }
+ return;
+ }
+
const row = event.target.closest('[data-job-row]');
if (!row) return;
await loadJobDetails(Number(row.getAttribute('data-job-row')));
diff --git a/app/modules/also/backend/router.py b/app/modules/also/backend/router.py
index c52caf6..19c141a 100644
--- a/app/modules/also/backend/router.py
+++ b/app/modules/also/backend/router.py
@@ -97,6 +97,15 @@ async def manual_map_product_for_line(
)
+@router.post("/also/import-lines/{line_id}/create-product")
+async def create_product_for_import_line(line_id: int, request: Request):
+ """Create the missing Hub product from an ALSO line and map it immediately."""
+ return also_service.create_product_for_line(
+ line_id=line_id,
+ updated_by_user_id=_user_id_from_request(request),
+ )
+
+
@router.post("/also/import-upload")
async def upload_import_file(
request: Request,
diff --git a/app/modules/also/backend/service.py b/app/modules/also/backend/service.py
index b889c6f..112bb67 100644
--- a/app/modules/also/backend/service.py
+++ b/app/modules/also/backend/service.py
@@ -2036,39 +2036,76 @@ class AlsoService:
}
)
+ aggregate_key = f"also-cloud-{customer_id}-{period_key}"
+ # A manual mapping can make the customer's products ready one at a time.
+ # Reuse the pending draft for the same customer/month rather than creating
+ # a separate order draft per product line.
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
- """,
- (
- f"ALSO Cloud {customer_name} - {period_key}",
- customer_id,
- _json_dumps(draft_lines),
- "Genereret fra ALSO Cloud Billing approval",
- 1,
- approved_by_user_id,
- _json_dumps({"source": "also_cloud_billing"}),
- f"also-cloud-{customer_id}-{period_key}",
- ),
+ """SELECT id, lines_json
+ FROM ordre_drafts
+ WHERE customer_id = %s
+ AND invoice_aggregate_key = %s
+ AND sync_status = 'pending'
+ ORDER BY id ASC
+ LIMIT 1""",
+ (customer_id, aggregate_key),
)
-
- draft_id = int(draft["id"]) if draft and draft.get("id") else None
+ if draft and draft.get("id"):
+ existing_lines = draft.get("lines_json") or []
+ if isinstance(existing_lines, str):
+ try:
+ existing_lines = json.loads(existing_lines)
+ except (TypeError, ValueError):
+ existing_lines = []
+ existing_keys = {
+ str(item.get("line_key"))
+ for item in existing_lines
+ if isinstance(item, dict) and item.get("line_key")
+ }
+ merged_lines = list(existing_lines) + [
+ item for item in draft_lines if str(item.get("line_key")) not in existing_keys
+ ]
+ execute_query(
+ """UPDATE ordre_drafts
+ SET lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s""",
+ (_json_dumps(merged_lines), int(draft["id"])),
+ )
+ draft_id = int(draft["id"])
+ else:
+ 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
+ """,
+ (
+ f"ALSO Cloud {customer_name} - {period_key}",
+ customer_id,
+ _json_dumps(draft_lines),
+ "Genereret fra ALSO Cloud Billing approval",
+ 1,
+ approved_by_user_id,
+ _json_dumps({"source": "also_cloud_billing"}),
+ aggregate_key,
+ ),
+ )
+ draft_id = int(draft["id"]) if draft and draft.get("id") else None
if not draft_id:
raise HTTPException(status_code=500, detail="Failed creating ordre draft from ALSO approval")
- draft_ids.append(draft_id)
+ if draft_id not in draft_ids:
+ draft_ids.append(draft_id)
line_id_values = [int(line["id"]) for line in group_lines]
placeholders = ",".join(["%s"] * len(line_id_values))
@@ -2434,6 +2471,7 @@ class AlsoService:
""",
(line_id,),
)
+ completion = self._complete_import_job(int(line["import_job_id"]))
return {
"success": True,
@@ -2448,6 +2486,7 @@ class AlsoService:
"matching_result": matching_result,
"validation_result": validation_result,
"approval_result": approval_result,
+ "completion": completion,
"line": refreshed_line,
}
@@ -2546,6 +2585,7 @@ class AlsoService:
""",
(line_id,),
)
+ completion = self._complete_import_job(int(line["import_job_id"]))
return {
"success": True,
@@ -2562,9 +2602,56 @@ class AlsoService:
"matching_result": matching_result,
"validation_result": validation_result,
"approval_result": approval_result,
+ "completion": completion,
"line": refreshed_line,
}
+ def create_product_for_line(
+ self,
+ *,
+ line_id: int,
+ updated_by_user_id: Optional[int],
+ ) -> Dict[str, Any]:
+ """Create a Hub subscription product from an ALSO line, then persist its mapping."""
+ self._assert_enabled()
+ line = execute_query_single(
+ "SELECT * FROM also_import_lines WHERE id = %s",
+ (line_id,),
+ )
+ if not line:
+ raise HTTPException(status_code=404, detail="ALSO import line not found")
+ if line.get("matched_product_id"):
+ raise HTTPException(status_code=409, detail="Linjen er allerede koblet til et Hub-produkt")
+
+ material_number = _normalized_text(line.get("material_number"))
+ product_name = _normalized_text(line.get("product_name"))
+ if not material_number or not product_name:
+ raise HTTPException(
+ status_code=400,
+ detail="Linjen mangler produktnavn eller ALSO-materialenummer og kan ikke oprette en vare",
+ )
+
+ vendor = _normalized_text(line.get("vendor")) or "ALSO"
+ existing = execute_query_single(
+ """SELECT id FROM products
+ WHERE deleted_at IS NULL AND (sku_internal = %s OR supplier_sku = %s)
+ ORDER BY id ASC LIMIT 1""",
+ (material_number, material_number),
+ )
+ product_created = not bool(existing and existing.get("id"))
+ product_id = int(existing["id"]) if existing and existing.get("id") else self._create_local_product_for_line(line, vendor=vendor)
+ if not product_id:
+ raise HTTPException(status_code=500, detail="Kunne ikke oprette Hub-produktet")
+
+ result = self.manual_map_product_for_line(
+ line_id=line_id,
+ product_id=product_id,
+ notes="Product created from ALSO Cloud Marketplace UI",
+ updated_by_user_id=updated_by_user_id,
+ )
+ result["product_created"] = product_created
+ return result
+
def upsert_product_mapping(self, payload: AlsoProductMappingUpsert) -> Dict[str, Any]:
self._assert_enabled()
rows = execute_query(
diff --git a/app/modules/bottom_bar/backend/router.py b/app/modules/bottom_bar/backend/router.py
index d52aed8..98f5dcf 100644
--- a/app/modules/bottom_bar/backend/router.py
+++ b/app/modules/bottom_bar/backend/router.py
@@ -673,6 +673,15 @@ def _get_next_unassigned_case() -> Optional[dict]:
WHERE deleted_at IS NULL
AND ansvarlig_bruger_id IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
+ AND (
+ LOWER(COALESCE(template_key, '')) IN ('ticket', 'support')
+ OR EXISTS (
+ SELECT 1
+ FROM groups support_group
+ WHERE support_group.id = sag_sager.assigned_group_id
+ AND LOWER(support_group.name) LIKE ANY(ARRAY['%support%', '%teknik%', '%technician%'])
+ )
+ )
ORDER BY
CASE
WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0
diff --git a/app/modules/bottom_bar/backend/service.py b/app/modules/bottom_bar/backend/service.py
index 2d9b98a..12749d1 100644
--- a/app/modules/bottom_bar/backend/service.py
+++ b/app/modules/bottom_bar/backend/service.py
@@ -580,20 +580,35 @@ def get_own_timer_snapshot(user_id: Optional[int], paused_limit: int = 10) -> Di
}
-def get_unassigned_open_cases(limit: int = 25) -> Dict[str, Any]:
+def get_unassigned_open_cases(limit: int = 25, support_only: bool = False) -> Dict[str, Any]:
limit_safe = max(1, min(int(limit or 25), 100))
+ support_filter = """
+ AND (
+ LOWER(COALESCE(s.template_key, '')) IN ('ticket', 'support')
+ OR EXISTS (
+ SELECT 1
+ FROM groups support_group
+ WHERE support_group.id = s.assigned_group_id
+ AND LOWER(support_group.name) LIKE ANY(ARRAY['%%support%%', '%%teknik%%', '%%technician%%'])
+ )
+ )
+ """ if support_only else ""
rows = execute_query(
- """
+ f"""
SELECT
s.id,
s.titel,
s.priority,
s.created_at,
- s.updated_at
+ s.updated_at,
+ EXTRACT(EPOCH FROM (NOW() - s.created_at))::int AS age_seconds,
+ c.name AS customer_name
FROM sag_sager s
+ LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL
+ {support_filter}
ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC
LIMIT %s
""",
@@ -601,12 +616,13 @@ def get_unassigned_open_cases(limit: int = 25) -> Dict[str, Any]:
) or []
count_row = execute_query_single(
- """
+ f"""
SELECT COUNT(*)::int AS count
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL
+ {support_filter}
"""
)
@@ -618,17 +634,20 @@ def get_unassigned_open_cases(limit: int = 25) -> Dict[str, Any]:
"priority": row.get("priority") or "normal",
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
+ "age_seconds": int(row.get("age_seconds") or 0),
+ "customer_name": row.get("customer_name"),
}
for row in rows
],
"count": _safe_count(count_row),
"filter_meta": {
"route": "/api/v1/bottom-bar/boss/unassigned-cases",
- "query": {"limit": limit_safe, "only_open": True, "only_unassigned": True},
+ "query": {"limit": limit_safe, "only_open": True, "only_unassigned": True, "support_only": support_only},
"sql_guarantee": [
"s.deleted_at IS NULL",
"LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')",
"s.ansvarlig_bruger_id IS NULL",
+ *(["Supporttype eller supportgruppe"] if support_only else []),
],
},
}
@@ -987,8 +1006,11 @@ def build_bottom_bar_state(
technicians_today: List[Dict[str, Any]] = []
escalation_cases: List[Dict[str, Any]] = []
unassigned_cases: List[Dict[str, Any]] = []
+ support_unassigned_count = 0
if can_view_boss:
+ support_unassigned_open_cases = get_unassigned_open_cases(limit=12, support_only=True)
+ support_unassigned_count = int(support_unassigned_open_cases.get("count") or 0)
team_workload = execute_query(
"""
SELECT
@@ -1091,8 +1113,10 @@ def build_bottom_bar_state(
"id": row.get("id"),
"titel": row.get("title"),
"priority": row.get("priority"),
+ "customer_name": row.get("customer_name"),
+ "age_seconds": int(row.get("age_seconds") or 0),
}
- for row in (unassigned_open_cases.get("items") or [])
+ for row in (support_unassigned_open_cases.get("items") or [])
]
sections = {
@@ -1161,6 +1185,7 @@ def build_bottom_bar_state(
"can_view": can_view_boss,
"stats": {
"unassigned": status.get("sager_unassigned", 0),
+ "support_unassigned": support_unassigned_count,
"active_employees": _safe_count(
execute_query_single(
"SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer = TRUE AND slut_tid IS NULL"
diff --git a/app/modules/locations/templates/detail.html b/app/modules/locations/templates/detail.html
index 8ccf012..37b4789 100644
--- a/app/modules/locations/templates/detail.html
+++ b/app/modules/locations/templates/detail.html
@@ -1617,6 +1617,20 @@ document.addEventListener('DOMContentLoaded', function() {
return (hardware?.switch_ports || []).filter(port => port && port.port_number !== null && port.port_number !== undefined);
}
+ function bulkSwitchPortStatus(port) {
+ if (port?.outlet) {
+ const outletName = port.outlet.outlet_number || `vægstik #${port.outlet.id}`;
+ return `konfigureret: ${outletName}${port.outlet.is_wan ? ' (WAN)' : ''}`;
+ }
+ if (port?.hardware_link) {
+ const target = [port.hardware_link.target_brand, port.hardware_link.target_model]
+ .filter(Boolean).join(' ') || 'hardware';
+ const targetPort = port.hardware_link.target_port ? ` port ${port.hardware_link.target_port}` : '';
+ return `konfigureret: ${target}${targetPort}`;
+ }
+ return 'ledig';
+ }
+
function populateBulkFieldPorts() {
const ports = selectableBulkFieldPorts(selectedBulkField());
document.getElementById('bulkFromPort').innerHTML = ports
@@ -1647,7 +1661,7 @@ document.addEventListener('DOMContentLoaded', function() {
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
const ports = selectableSwitchPorts(hardware);
document.getElementById('bulkSwitchStart').innerHTML = ports
- .map((port, index) => ``)
+ .map((port, index) => ``)
.join('');
}
@@ -1683,8 +1697,12 @@ document.addEventListener('DOMContentLoaded', function() {
const lastPort = fieldPorts[to]?.port_number || '—';
const firstSwitchPort = selectedSwitchPorts[0]?.port_number || '—';
const lastSwitchPort = selectedSwitchPorts[selectedSwitchPorts.length - 1]?.port_number || '—';
+ const configuredSwitchPorts = selectedSwitchPorts.filter(port => port.outlet || port.hardware_link);
+ const configuredWarning = configuredSwitchPorts.length
+ ? `${configuredSwitchPorts.length} switch-port(e) i området er allerede konfigureret: ${configuredSwitchPorts.map(port => port.port_number).join(', ')}
`
+ : '';
document.getElementById('bulkPatchPreview').innerHTML = count && selectedSwitchPorts.length === count
- ? `${count} forbindelser: ${field?.name || '—'} port ${firstPort}–${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${firstSwitchPort}–${lastSwitchPort}`
+ ? `${count} forbindelser: ${field?.name || '—'} port ${firstPort}–${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${firstSwitchPort}–${lastSwitchPort}${configuredWarning}`
: count
? 'Det valgte område går ud over de oprettede switch-porte.'
: 'Vælg et gyldigt portområde.';
diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py
index 1c045df..0fe3ee0 100644
--- a/app/modules/sag/frontend/views.py
+++ b/app/modules/sag/frontend/views.py
@@ -1015,7 +1015,34 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
customer = None
hovedkontakt = None
if sag.get('customer_id'):
- customer_query = "SELECT * FROM customers WHERE id = %s"
+ customer_query = """
+ SELECT
+ c.*,
+ (vendor_link.vendor_id IS NOT NULL) AS is_vendor,
+ vendor_link.vendor_id,
+ vendor_link.vendor_name,
+ vendor_link.relationship_type AS vendor_relationship_type
+ FROM customers c
+ LEFT JOIN LATERAL (
+ SELECT
+ cvl.vendor_id,
+ v.name AS vendor_name,
+ cvl.relationship_type
+ FROM customer_vendor_links cvl
+ JOIN vendors v ON v.id = cvl.vendor_id
+ WHERE cvl.customer_id = c.id
+ AND v.is_active IS NOT FALSE
+ ORDER BY
+ CASE cvl.relationship_type
+ WHEN 'supplier' THEN 0
+ WHEN 'reseller' THEN 1
+ ELSE 2
+ END,
+ cvl.id
+ LIMIT 1
+ ) vendor_link ON TRUE
+ WHERE c.id = %s
+ """
customer_result = execute_query(customer_query, (sag['customer_id'],))
if customer_result:
customer = customer_result[0]
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index 700e404..43c37cd 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -62,13 +62,14 @@
left: 0;
right: 0;
z-index: var(--bottom-bar-zindex);
- background: rgba(var(--bg-card-rgb), 0.85); /* Glassmorphism */
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- border-top: 1px solid rgba(var(--text-primary-rgb), 0.1);
- box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.08);
- border-top-left-radius: 16px;
- border-top-right-radius: 16px;
+ background: rgba(var(--bg-card-rgb), 0.96);
+ backdrop-filter: blur(18px) saturate(1.15);
+ -webkit-backdrop-filter: blur(18px) saturate(1.15);
+ border: 1px solid rgba(var(--text-primary-rgb), 0.1);
+ border-bottom: 0;
+ box-shadow: 0 -12px 38px rgba(15, 23, 42, 0.14);
+ border-top-left-radius: 20px;
+ border-top-right-radius: 20px;
min-height: var(--bottom-bar-height);
padding: 0.5rem 1rem calc(0.5rem + env(safe-area-inset-bottom, 0px));
transform: translateY(calc(100% + 12px));
@@ -97,7 +98,7 @@
min-height: calc(var(--bottom-bar-height) - 10px);
display: flex;
align-items: center;
- gap: 0.45rem;
+ gap: 0.6rem;
justify-content: space-between;
}
@@ -159,12 +160,12 @@
}
.global-bottom-bar .bb-sheet-toggle {
- border: 1px solid rgba(var(--text-primary-rgb), 0.1);
- background: transparent;
+ border: 1px solid rgba(15, 76, 117, 0.18);
+ background: var(--accent-light);
color: var(--text-primary);
- border-radius: 50%;
- width: 36px;
- height: 36px;
+ border-radius: 10px;
+ width: 38px;
+ height: 34px;
padding: 0;
display: inline-flex;
align-items: center;
@@ -173,7 +174,8 @@
transition: all 0.2s ease;
}
.global-bottom-bar .bb-sheet-toggle:hover {
- background: var(--accent-light);
+ background: var(--accent);
+ color: #fff;
transform: translateY(-1px);
}
.global-bottom-bar .bb-sheet-toggle span { display: none; }
@@ -183,10 +185,10 @@
}
.global-bottom-bar .bb-action-btn {
- border: 1px solid rgba(var(--text-primary-rgb), 0.1);
+ border: 1px solid rgba(var(--text-primary-rgb), 0.12);
background: var(--bg-card);
color: var(--text-primary);
- border-radius: 999px;
+ border-radius: 9px;
padding: 0.3rem 0.75rem;
font-size: 0.8rem;
font-weight: 600;
@@ -244,12 +246,12 @@
}
.global-bottom-bar .bb-chip {
- border: 1px solid rgba(var(--text-primary-rgb), 0.1);
- background: var(--accent-light);
+ border: 1px solid rgba(var(--text-primary-rgb), 0.12);
+ background: rgba(var(--text-primary-rgb), 0.045);
color: var(--text-primary);
- border-radius: 999px;
- padding: 0.35rem 0.85rem;
- font-size: 0.8rem;
+ border-radius: 10px;
+ padding: 0.34rem 0.65rem;
+ font-size: 0.78rem;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
@@ -386,14 +388,14 @@
.global-bottom-bar .bb-sheet-inner {
background: var(--bg-card);
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
- border-radius: 14px;
- display: grid;
- grid-template-columns: 160px minmax(0, 1fr);
+ border-radius: 16px;
+ display: flex;
+ flex-direction: column;
min-height: 240px;
max-height: min(52vh, 420px);
height: min(52vh, 420px);
overflow: hidden;
- box-shadow: inset 0 2px 10px rgba(0,0,0,0.02);
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
margin-top: 0.5rem;
}
.global-bottom-bar .bb-sheet-inner > * {
@@ -401,30 +403,35 @@
}
.global-bottom-bar .bb-side-tabs {
- border-right: 1px solid rgba(var(--text-primary-rgb), 0.08);
- background: rgba(var(--text-primary-rgb), 0.03);
- padding: 0.75rem 0.5rem;
- display: grid;
- gap: 0.4rem;
- align-content: start;
- min-height: 0;
- overflow-y: auto;
+ border-right: 0;
+ border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.09);
+ background: rgba(var(--text-primary-rgb), 0.025);
+ padding: 0.55rem 0.65rem;
+ display: flex;
+ gap: 0.35rem;
+ align-items: center;
+ min-height: auto;
+ overflow-x: auto;
+ scrollbar-width: none;
}
+ .global-bottom-bar .bb-side-tabs::-webkit-scrollbar { display: none; }
.global-bottom-bar .bb-tab-btn {
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
- border-radius: 8px;
- text-align: left;
- font-size: 0.85rem;
+ border-radius: 9px;
+ text-align: center;
+ font-size: 0.8rem;
font-weight: 600;
- padding: 0.5rem 0.75rem;
+ padding: 0.45rem 0.7rem;
line-height: 1.3;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
+ white-space: nowrap;
+ flex: 0 0 auto;
}
.global-bottom-bar .bb-tab-btn i {
font-size: 1rem;
@@ -453,14 +460,14 @@
}
.global-bottom-bar .bb-tab-btn.is-active {
- background: var(--bg-card);
- color: var(--accent);
- box-shadow: 0 2px 6px rgba(0,0,0,0.06);
+ background: var(--accent);
+ color: #fff;
+ box-shadow: 0 4px 12px rgba(15, 76, 117, 0.22);
font-weight: 700;
}
.global-bottom-bar .bb-tab-btn.is-active i {
opacity: 1;
- color: var(--accent);
+ color: inherit;
}
[data-bs-theme="dark"] .global-bottom-bar .bb-tab-btn .bb-tab-badge {
background: rgba(255, 138, 148, 0.18);
@@ -468,7 +475,7 @@
}
.global-bottom-bar .bb-tab-content {
- padding: 0.75rem 0.9rem 0.9rem;
+ padding: 0.85rem 1rem 1rem;
overflow: hidden;
display: flex;
flex-direction: column;
@@ -510,6 +517,37 @@
transform: translateX(2px);
box-shadow: 0 4px 14px rgba(0,0,0,0.08);
}
+ .global-bottom-bar .bb-boss-hero {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.8rem 0.9rem;
+ border-radius: 12px;
+ color: #fff;
+ background: linear-gradient(120deg, #0f4c75, #176b87);
+ }
+ .global-bottom-bar .bb-boss-eyebrow { font-size: 0.72rem; opacity: 0.8; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
+ .global-bottom-bar .bb-boss-heading { font-weight: 700; font-size: 0.95rem; margin-top: 0.1rem; }
+ .global-bottom-bar .bb-boss-hero .btn { background: #fff; color: #0f4c75; border: 0; white-space: nowrap; }
+ .global-bottom-bar .bb-boss-kpis .border { border-color: rgba(var(--text-primary-rgb), 0.08) !important; border-radius: 10px !important; }
+ .global-bottom-bar .bb-support-queue-card {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.65rem 0.7rem;
+ border: 1px solid rgba(245, 158, 11, 0.28);
+ border-left: 4px solid #f59e0b;
+ border-radius: 10px;
+ background: rgba(245, 158, 11, 0.055);
+ margin-bottom: 0.45rem;
+ }
+ .global-bottom-bar .bb-support-queue-main { min-width: 0; }
+ .global-bottom-bar .bb-support-queue-main strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .global-bottom-bar .bb-support-queue-actions { display: flex; align-items: center; gap: 0.35rem; flex: 0 0 auto; }
+ .global-bottom-bar .bb-support-queue-actions select { width: 175px; }
+ .global-bottom-bar .bb-boss-empty { padding: 0.75rem; border-radius: 10px; color: #146c43; background: rgba(25, 135, 84, 0.1); font-weight: 600; }
.global-bottom-bar .bb-messages-layout {
display: flex;
flex-direction: column;
@@ -651,7 +689,6 @@
}
.global-bottom-bar .bb-sheet-inner {
- grid-template-columns: 1fr;
min-height: 240px;
}
@@ -670,9 +707,11 @@
.global-bottom-bar .bb-side-tabs {
border-right: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
- grid-template-columns: repeat(5, minmax(90px, 1fr));
overflow-x: auto;
}
+ .global-bottom-bar .bb-support-queue-card { align-items: stretch; flex-direction: column; }
+ .global-bottom-bar .bb-support-queue-actions { width: 100%; }
+ .global-bottom-bar .bb-support-queue-actions select { width: auto; flex: 1 1 auto; }
}
.navbar {
@@ -1332,7 +1371,7 @@
-
+
Overblik
@@ -1450,7 +1489,7 @@ if (bmcOriginalFetch) {
-
+