From 533a65233737d9443eeb9d336739b8eba21217f3 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 12 Aug 2026 07:20:04 +0200 Subject: [PATCH] feat: Add product creation functionality for ALSO import lines - Implemented a button to create a product in Hub for unmatched ALSO lines in the frontend. - Added a new API endpoint to handle product creation from an ALSO import line. - Enhanced the service layer to create a Hub product and map it to the corresponding line. - Updated the bottom bar to include support case management features, including assigning cases to technicians. - Improved the UI for displaying unassigned support cases and added functionality for assigning cases to users. - Refactored CSS styles for better visual consistency in the bottom bar and support queue cards. --- app/customers/frontend/customer_detail.html | 51 ------- app/economy/frontend/also_cloud.html | 42 ++++++ app/modules/also/backend/router.py | 9 ++ app/modules/also/backend/service.py | 143 ++++++++++++++++---- app/modules/bottom_bar/backend/router.py | 9 ++ app/modules/bottom_bar/backend/service.py | 37 ++++- app/modules/locations/templates/detail.html | 22 ++- app/modules/sag/frontend/views.py | 29 +++- app/shared/frontend/base.html | 131 +++++++++++------- static/js/bottom-bar.js | 70 ++++++++-- 10 files changed, 399 insertions(+), 144 deletions(-) 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) { - +