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.
This commit is contained in:
Christian 2026-08-12 07:20:04 +02:00
parent 56cc1bdcc4
commit 533a652337
10 changed files with 399 additions and 144 deletions

View File

@ -1043,18 +1043,6 @@
<span class="info-label">Standard timepris</span> <span class="info-label">Standard timepris</span>
<span class="info-value" id="standardHourlyRate">-</span> <span class="info-value" id="standardHourlyRate">-</span>
</div> </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>
@ -1983,25 +1971,6 @@
<input type="number" class="form-control" id="editStandardHourlyRate" min="0" step="0.01"> <input type="number" class="form-control" id="editStandardHourlyRate" min="0" step="0.01">
</div> </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">
@ -2754,21 +2723,10 @@ function displayCustomer(customer) {
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 standardMargin = customer.standard_margin_percent ?? customerDefaultMarginPercent;
const invoiceFee = customer.invoice_fee_amount ?? customerDefaultInvoiceFee;
const standardHourlyRate = customer.standard_hourly_rate ?? customerDefaultHourlyRate; const standardHourlyRate = customer.standard_hourly_rate ?? customerDefaultHourlyRate;
const freight = customer.special_freight_price;
document.getElementById('standardMarginPercent').textContent = `${Number(standardMargin).toFixed(2)} %`; document.getElementById('standardMarginPercent').textContent = `${Number(standardMargin).toFixed(2)} %`;
document.getElementById('standardHourlyRate').textContent = `${Number(standardHourlyRate).toFixed(2)} DKK`; 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>';
@ -5560,9 +5518,6 @@ function editCustomer() {
document.getElementById('editCity').value = customerData.city || ''; document.getElementById('editCity').value = customerData.city || '';
document.getElementById('editStandardMarginPercent').value = (customerData.standard_margin_percent ?? customerDefaultMarginPercent); document.getElementById('editStandardMarginPercent').value = (customerData.standard_margin_percent ?? customerDefaultMarginPercent);
document.getElementById('editStandardHourlyRate').value = (customerData.standard_hourly_rate ?? customerDefaultHourlyRate); 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
@ -5573,9 +5528,6 @@ function editCustomer() {
async function saveCustomerEdit() { async function saveCustomerEdit() {
const marginValue = document.getElementById('editStandardMarginPercent').value; const marginValue = document.getElementById('editStandardMarginPercent').value;
const hourlyRateValue = document.getElementById('editStandardHourlyRate').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,
@ -5591,9 +5543,6 @@ async function saveCustomerEdit() {
city: document.getElementById('editCity').value || null, city: document.getElementById('editCity').value || null,
standard_margin_percent: marginValue === '' ? customerDefaultMarginPercent : Number(marginValue), standard_margin_percent: marginValue === '' ? customerDefaultMarginPercent : Number(marginValue),
standard_hourly_rate: hourlyRateValue === '' ? customerDefaultHourlyRate : Number(hourlyRateValue), 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
}; };

View File

@ -795,6 +795,11 @@
<button class="btn btn-sm btn-outline-secondary js-product-map-toggle" data-line-id="${line.id}" type="button"> <button class="btn btn-sm btn-outline-secondary js-product-map-toggle" data-line-id="${line.id}" type="button">
${state.activeProductMapLineId === Number(line.id) ? 'Luk produkt' : 'Ret produkt'} ${state.activeProductMapLineId === Number(line.id) ? 'Luk produkt' : 'Ret produkt'}
</button> </button>
${!line.matched_product_id ? `
<button class="btn btn-sm btn-primary js-product-create ms-1" data-line-id="${line.id}" type="button">
<i class="bi bi-plus-circle me-1"></i>Opret produkt i Hub
</button>
` : ''}
</div> </div>
${state.activeProductMapLineId === Number(line.id) ? renderProductMapEditor(line) : ''} ${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 () => { const autoMapSelectedJob = async () => {
if (!state.selectedJobId) return; if (!state.selectedJobId) return;
el('uploadResult').textContent = `Kører auto-map for job #${state.selectedJobId}...`; el('uploadResult').textContent = `Kører auto-map for job #${state.selectedJobId}...`;
@ -1140,6 +1163,25 @@
return; 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 = '<span class="spinner-border spinner-border-sm me-1"></span>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]'); const row = event.target.closest('[data-job-row]');
if (!row) return; if (!row) return;
await loadJobDetails(Number(row.getAttribute('data-job-row'))); await loadJobDetails(Number(row.getAttribute('data-job-row')));

View File

@ -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") @router.post("/also/import-upload")
async def upload_import_file( async def upload_import_file(
request: Request, request: Request,

View File

@ -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( draft = execute_query_single(
""" """SELECT id, lines_json
INSERT INTO ordre_drafts ( FROM ordre_drafts
title, WHERE customer_id = %s
customer_id, AND invoice_aggregate_key = %s
lines_json, AND sync_status = 'pending'
notes, ORDER BY id ASC
layout_number, LIMIT 1""",
created_by_user_id, (customer_id, aggregate_key),
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}",
),
) )
if draft and draft.get("id"):
draft_id = int(draft["id"]) if draft and draft.get("id") else None 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: if not draft_id:
raise HTTPException(status_code=500, detail="Failed creating ordre draft from ALSO approval") 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] line_id_values = [int(line["id"]) for line in group_lines]
placeholders = ",".join(["%s"] * len(line_id_values)) placeholders = ",".join(["%s"] * len(line_id_values))
@ -2434,6 +2471,7 @@ class AlsoService:
""", """,
(line_id,), (line_id,),
) )
completion = self._complete_import_job(int(line["import_job_id"]))
return { return {
"success": True, "success": True,
@ -2448,6 +2486,7 @@ class AlsoService:
"matching_result": matching_result, "matching_result": matching_result,
"validation_result": validation_result, "validation_result": validation_result,
"approval_result": approval_result, "approval_result": approval_result,
"completion": completion,
"line": refreshed_line, "line": refreshed_line,
} }
@ -2546,6 +2585,7 @@ class AlsoService:
""", """,
(line_id,), (line_id,),
) )
completion = self._complete_import_job(int(line["import_job_id"]))
return { return {
"success": True, "success": True,
@ -2562,9 +2602,56 @@ class AlsoService:
"matching_result": matching_result, "matching_result": matching_result,
"validation_result": validation_result, "validation_result": validation_result,
"approval_result": approval_result, "approval_result": approval_result,
"completion": completion,
"line": refreshed_line, "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]: def upsert_product_mapping(self, payload: AlsoProductMappingUpsert) -> Dict[str, Any]:
self._assert_enabled() self._assert_enabled()
rows = execute_query( rows = execute_query(

View File

@ -673,6 +673,15 @@ def _get_next_unassigned_case() -> Optional[dict]:
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND ansvarlig_bruger_id IS NULL AND ansvarlig_bruger_id IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') 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 ORDER BY
CASE CASE
WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0 WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0

View File

@ -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)) 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( rows = execute_query(
""" f"""
SELECT SELECT
s.id, s.id,
s.titel, s.titel,
s.priority, s.priority,
s.created_at, 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 FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL AND s.ansvarlig_bruger_id IS NULL
{support_filter}
ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC
LIMIT %s LIMIT %s
""", """,
@ -601,12 +616,13 @@ def get_unassigned_open_cases(limit: int = 25) -> Dict[str, Any]:
) or [] ) or []
count_row = execute_query_single( count_row = execute_query_single(
""" f"""
SELECT COUNT(*)::int AS count SELECT COUNT(*)::int AS count
FROM sag_sager s FROM sag_sager s
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL 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", "priority": row.get("priority") or "normal",
"created_at": row.get("created_at"), "created_at": row.get("created_at"),
"updated_at": row.get("updated_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 for row in rows
], ],
"count": _safe_count(count_row), "count": _safe_count(count_row),
"filter_meta": { "filter_meta": {
"route": "/api/v1/bottom-bar/boss/unassigned-cases", "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": [ "sql_guarantee": [
"s.deleted_at IS NULL", "s.deleted_at IS NULL",
"LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')", "LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')",
"s.ansvarlig_bruger_id IS NULL", "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]] = [] technicians_today: List[Dict[str, Any]] = []
escalation_cases: List[Dict[str, Any]] = [] escalation_cases: List[Dict[str, Any]] = []
unassigned_cases: List[Dict[str, Any]] = [] unassigned_cases: List[Dict[str, Any]] = []
support_unassigned_count = 0
if can_view_boss: 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( team_workload = execute_query(
""" """
SELECT SELECT
@ -1091,8 +1113,10 @@ def build_bottom_bar_state(
"id": row.get("id"), "id": row.get("id"),
"titel": row.get("title"), "titel": row.get("title"),
"priority": row.get("priority"), "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 = { sections = {
@ -1161,6 +1185,7 @@ def build_bottom_bar_state(
"can_view": can_view_boss, "can_view": can_view_boss,
"stats": { "stats": {
"unassigned": status.get("sager_unassigned", 0), "unassigned": status.get("sager_unassigned", 0),
"support_unassigned": support_unassigned_count,
"active_employees": _safe_count( "active_employees": _safe_count(
execute_query_single( execute_query_single(
"SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer = TRUE AND slut_tid IS NULL" "SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer = TRUE AND slut_tid IS NULL"

View File

@ -1617,6 +1617,20 @@ document.addEventListener('DOMContentLoaded', function() {
return (hardware?.switch_ports || []).filter(port => port && port.port_number !== null && port.port_number !== undefined); 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() { function populateBulkFieldPorts() {
const ports = selectableBulkFieldPorts(selectedBulkField()); const ports = selectableBulkFieldPorts(selectedBulkField());
document.getElementById('bulkFromPort').innerHTML = ports 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 hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
const ports = selectableSwitchPorts(hardware); const ports = selectableSwitchPorts(hardware);
document.getElementById('bulkSwitchStart').innerHTML = ports document.getElementById('bulkSwitchStart').innerHTML = ports
.map((port, index) => `<option value="${index}">${port.port_number}</option>`) .map((port, index) => `<option value="${index}">Port ${port.port_number} — ${bulkSwitchPortStatus(port)}</option>`)
.join(''); .join('');
} }
@ -1683,8 +1697,12 @@ document.addEventListener('DOMContentLoaded', function() {
const lastPort = fieldPorts[to]?.port_number || '—'; const lastPort = fieldPorts[to]?.port_number || '—';
const firstSwitchPort = selectedSwitchPorts[0]?.port_number || '—'; const firstSwitchPort = selectedSwitchPorts[0]?.port_number || '—';
const lastSwitchPort = selectedSwitchPorts[selectedSwitchPorts.length - 1]?.port_number || '—'; const lastSwitchPort = selectedSwitchPorts[selectedSwitchPorts.length - 1]?.port_number || '—';
const configuredSwitchPorts = selectedSwitchPorts.filter(port => port.outlet || port.hardware_link);
const configuredWarning = configuredSwitchPorts.length
? `<div class="text-danger fw-semibold mt-2"><i class="bi bi-exclamation-triangle me-1"></i>${configuredSwitchPorts.length} switch-port(e) i området er allerede konfigureret: ${configuredSwitchPorts.map(port => port.port_number).join(', ')}</div>`
: '';
document.getElementById('bulkPatchPreview').innerHTML = count && selectedSwitchPorts.length === count document.getElementById('bulkPatchPreview').innerHTML = count && selectedSwitchPorts.length === count
? `<strong>${count} forbindelser:</strong> ${field?.name || '—'} port ${firstPort}${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${firstSwitchPort}${lastSwitchPort}` ? `<strong>${count} forbindelser:</strong> ${field?.name || '—'} port ${firstPort}${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${firstSwitchPort}${lastSwitchPort}${configuredWarning}`
: count : count
? 'Det valgte område går ud over de oprettede switch-porte.' ? 'Det valgte område går ud over de oprettede switch-porte.'
: 'Vælg et gyldigt portområde.'; : 'Vælg et gyldigt portområde.';

View File

@ -1015,7 +1015,34 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
customer = None customer = None
hovedkontakt = None hovedkontakt = None
if sag.get('customer_id'): 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'],)) customer_result = execute_query(customer_query, (sag['customer_id'],))
if customer_result: if customer_result:
customer = customer_result[0] customer = customer_result[0]

View File

@ -62,13 +62,14 @@
left: 0; left: 0;
right: 0; right: 0;
z-index: var(--bottom-bar-zindex); z-index: var(--bottom-bar-zindex);
background: rgba(var(--bg-card-rgb), 0.85); /* Glassmorphism */ background: rgba(var(--bg-card-rgb), 0.96);
backdrop-filter: blur(12px); backdrop-filter: blur(18px) saturate(1.15);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(18px) saturate(1.15);
border-top: 1px solid rgba(var(--text-primary-rgb), 0.1); border: 1px solid rgba(var(--text-primary-rgb), 0.1);
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.08); border-bottom: 0;
border-top-left-radius: 16px; box-shadow: 0 -12px 38px rgba(15, 23, 42, 0.14);
border-top-right-radius: 16px; border-top-left-radius: 20px;
border-top-right-radius: 20px;
min-height: var(--bottom-bar-height); min-height: var(--bottom-bar-height);
padding: 0.5rem 1rem calc(0.5rem + env(safe-area-inset-bottom, 0px)); padding: 0.5rem 1rem calc(0.5rem + env(safe-area-inset-bottom, 0px));
transform: translateY(calc(100% + 12px)); transform: translateY(calc(100% + 12px));
@ -97,7 +98,7 @@
min-height: calc(var(--bottom-bar-height) - 10px); min-height: calc(var(--bottom-bar-height) - 10px);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.6rem;
justify-content: space-between; justify-content: space-between;
} }
@ -159,12 +160,12 @@
} }
.global-bottom-bar .bb-sheet-toggle { .global-bottom-bar .bb-sheet-toggle {
border: 1px solid rgba(var(--text-primary-rgb), 0.1); border: 1px solid rgba(15, 76, 117, 0.18);
background: transparent; background: var(--accent-light);
color: var(--text-primary); color: var(--text-primary);
border-radius: 50%; border-radius: 10px;
width: 36px; width: 38px;
height: 36px; height: 34px;
padding: 0; padding: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -173,7 +174,8 @@
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.global-bottom-bar .bb-sheet-toggle:hover { .global-bottom-bar .bb-sheet-toggle:hover {
background: var(--accent-light); background: var(--accent);
color: #fff;
transform: translateY(-1px); transform: translateY(-1px);
} }
.global-bottom-bar .bb-sheet-toggle span { display: none; } .global-bottom-bar .bb-sheet-toggle span { display: none; }
@ -183,10 +185,10 @@
} }
.global-bottom-bar .bb-action-btn { .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); background: var(--bg-card);
color: var(--text-primary); color: var(--text-primary);
border-radius: 999px; border-radius: 9px;
padding: 0.3rem 0.75rem; padding: 0.3rem 0.75rem;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
@ -244,12 +246,12 @@
} }
.global-bottom-bar .bb-chip { .global-bottom-bar .bb-chip {
border: 1px solid rgba(var(--text-primary-rgb), 0.1); border: 1px solid rgba(var(--text-primary-rgb), 0.12);
background: var(--accent-light); background: rgba(var(--text-primary-rgb), 0.045);
color: var(--text-primary); color: var(--text-primary);
border-radius: 999px; border-radius: 10px;
padding: 0.35rem 0.85rem; padding: 0.34rem 0.65rem;
font-size: 0.8rem; font-size: 0.78rem;
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.2;
cursor: pointer; cursor: pointer;
@ -386,14 +388,14 @@
.global-bottom-bar .bb-sheet-inner { .global-bottom-bar .bb-sheet-inner {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid rgba(var(--text-primary-rgb), 0.1); border: 1px solid rgba(var(--text-primary-rgb), 0.1);
border-radius: 14px; border-radius: 16px;
display: grid; display: flex;
grid-template-columns: 160px minmax(0, 1fr); flex-direction: column;
min-height: 240px; min-height: 240px;
max-height: min(52vh, 420px); max-height: min(52vh, 420px);
height: min(52vh, 420px); height: min(52vh, 420px);
overflow: hidden; 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; margin-top: 0.5rem;
} }
.global-bottom-bar .bb-sheet-inner > * { .global-bottom-bar .bb-sheet-inner > * {
@ -401,30 +403,35 @@
} }
.global-bottom-bar .bb-side-tabs { .global-bottom-bar .bb-side-tabs {
border-right: 1px solid rgba(var(--text-primary-rgb), 0.08); border-right: 0;
background: rgba(var(--text-primary-rgb), 0.03); border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.09);
padding: 0.75rem 0.5rem; background: rgba(var(--text-primary-rgb), 0.025);
display: grid; padding: 0.55rem 0.65rem;
gap: 0.4rem; display: flex;
align-content: start; gap: 0.35rem;
min-height: 0; align-items: center;
overflow-y: auto; 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 { .global-bottom-bar .bb-tab-btn {
border: 1px solid transparent; border: 1px solid transparent;
background: transparent; background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
border-radius: 8px; border-radius: 9px;
text-align: left; text-align: center;
font-size: 0.85rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
padding: 0.5rem 0.75rem; padding: 0.45rem 0.7rem;
line-height: 1.3; line-height: 1.3;
transition: all 0.2s ease; transition: all 0.2s ease;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
white-space: nowrap;
flex: 0 0 auto;
} }
.global-bottom-bar .bb-tab-btn i { .global-bottom-bar .bb-tab-btn i {
font-size: 1rem; font-size: 1rem;
@ -453,14 +460,14 @@
} }
.global-bottom-bar .bb-tab-btn.is-active { .global-bottom-bar .bb-tab-btn.is-active {
background: var(--bg-card); background: var(--accent);
color: var(--accent); color: #fff;
box-shadow: 0 2px 6px rgba(0,0,0,0.06); box-shadow: 0 4px 12px rgba(15, 76, 117, 0.22);
font-weight: 700; font-weight: 700;
} }
.global-bottom-bar .bb-tab-btn.is-active i { .global-bottom-bar .bb-tab-btn.is-active i {
opacity: 1; opacity: 1;
color: var(--accent); color: inherit;
} }
[data-bs-theme="dark"] .global-bottom-bar .bb-tab-btn .bb-tab-badge { [data-bs-theme="dark"] .global-bottom-bar .bb-tab-btn .bb-tab-badge {
background: rgba(255, 138, 148, 0.18); background: rgba(255, 138, 148, 0.18);
@ -468,7 +475,7 @@
} }
.global-bottom-bar .bb-tab-content { .global-bottom-bar .bb-tab-content {
padding: 0.75rem 0.9rem 0.9rem; padding: 0.85rem 1rem 1rem;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -510,6 +517,37 @@
transform: translateX(2px); transform: translateX(2px);
box-shadow: 0 4px 14px rgba(0,0,0,0.08); 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 { .global-bottom-bar .bb-messages-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -651,7 +689,6 @@
} }
.global-bottom-bar .bb-sheet-inner { .global-bottom-bar .bb-sheet-inner {
grid-template-columns: 1fr;
min-height: 240px; min-height: 240px;
} }
@ -670,9 +707,11 @@
.global-bottom-bar .bb-side-tabs { .global-bottom-bar .bb-side-tabs {
border-right: none; border-right: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.08); border-bottom: 1px solid rgba(0, 0, 0, 0.08);
grid-template-columns: repeat(5, minmax(90px, 1fr));
overflow-x: auto; 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 { .navbar {
@ -1332,7 +1371,7 @@
<button class="bb-tab-btn" type="button" data-bb-tab="tasks" role="tab" aria-selected="false"><i class="bi bi-calendar-check"></i> Opgaver</button> <button class="bb-tab-btn" type="button" data-bb-tab="tasks" role="tab" aria-selected="false"><i class="bi bi-calendar-check"></i> Opgaver</button>
<button class="bb-tab-btn" type="button" data-bb-tab="notes" role="tab" aria-selected="false"><i class="bi bi-journal-text"></i> Noter</button> <button class="bb-tab-btn" type="button" data-bb-tab="notes" role="tab" aria-selected="false"><i class="bi bi-journal-text"></i> Noter</button>
<!-- Vises kun for chefer, men her i markup --> <!-- Vises kun for chefer, men her i markup -->
<button class="bb-tab-btn" type="button" data-bb-tab="boss" role="tab" aria-selected="false"><i class="bi bi-person-workspace"></i> Chef</button> <button class="bb-tab-btn" type="button" data-bb-tab="boss" role="tab" aria-selected="false"><i class="bi bi-diagram-3"></i> Sagsfordeling</button>
</div> </div>
<div class="bb-tab-content" role="tabpanel" aria-live="polite"> <div class="bb-tab-content" role="tabpanel" aria-live="polite">
<div id="bbTabTitle" class="bb-tab-title"><i class="bi bi-bell me-1 text-accent"></i> <span class="bb-tab-title-text">Overblik</span></div> <div id="bbTabTitle" class="bb-tab-title"><i class="bi bi-bell me-1 text-accent"></i> <span class="bb-tab-title-text">Overblik</span></div>
@ -1450,7 +1489,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/telefoni.js?v=2.4"></script> <script src="/static/js/telefoni.js?v=2.4"></script>
<script src="/static/js/sms.js?v=1.1"></script> <script src="/static/js/sms.js?v=1.1"></script>
<script src="/static/js/bug-report.js?v=1.4"></script> <script src="/static/js/bug-report.js?v=1.4"></script>
<script src="/static/js/bottom-bar.js?v=2.43"></script> <script src="/static/js/bottom-bar.js?v=2.44"></script>
<script> <script>
// Dark Mode Toggle Logic // Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = true; window.BMC_CAN_CLICK_TO_CALL = true;

View File

@ -991,17 +991,23 @@
const techniciansToday = Array.isArray(boss.technicians_today) ? boss.technicians_today : []; const techniciansToday = Array.isArray(boss.technicians_today) ? boss.technicians_today : [];
const escalations = Array.isArray(boss.escalations) ? boss.escalations : []; const escalations = Array.isArray(boss.escalations) ? boss.escalations : [];
const unassigned = Array.isArray(boss.unassigned_cases) ? boss.unassigned_cases : []; const unassigned = Array.isArray(boss.unassigned_cases) ? boss.unassigned_cases : [];
const technicianOptions = techniciansToday.map(function (tech) {
return '<option value="' + Number(tech.user_id || 0) + '">' + esc(tech.owner_name || 'Tekniker') + ' · ' + Number(tech.open_cases || 0) + ' åbne</option>';
}).join('');
const out = [ const out = [
'<div class="row g-2">' + '<div class="bb-boss-hero">' +
'<div><div class="bb-boss-eyebrow"><i class="bi bi-headset me-1"></i>Support-ledelse</div><div class="bb-boss-heading">Fordel køen, før kunderne venter</div></div>' +
'<button class="btn btn-sm btn-primary" data-boss-action="auto_assign_next"><i class="bi bi-magic me-1"></i>Fordel næste</button>' +
'</div>',
'<div class="row g-2 bb-boss-kpis">' +
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Åbne sager</div><div class="fw-bold">' + Number(stats.open_cases || 0) + '</div></div></div>' + '<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Åbne sager</div><div class="fw-bold">' + Number(stats.open_cases || 0) + '</div></div></div>' +
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Hastesager</div><div class="fw-bold text-danger">' + Number(stats.urgent_cases || 0) + '</div></div></div>' + '<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Hastesager</div><div class="fw-bold text-danger">' + Number(stats.urgent_cases || 0) + '</div></div></div>' +
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Uden ansvarlig</div><div class="fw-bold text-warning">' + Number(stats.unassigned || 0) + '</div></div></div>' + '<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Support uden ejer</div><div class="fw-bold text-warning">' + Number(stats.support_unassigned || 0) + '</div></div></div>' +
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Stale >24t</div><div class="fw-bold text-danger">' + Number(stats.stale_urgent_cases || 0) + '</div></div></div>' + '<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Stale >24t</div><div class="fw-bold text-danger">' + Number(stats.stale_urgent_cases || 0) + '</div></div></div>' +
'</div>', '</div>',
'<div class="d-flex gap-2 flex-wrap mt-2">' + '<div class="d-flex gap-2 flex-wrap mt-2">' +
'<button class="btn btn-sm btn-primary" data-boss-action="auto_assign_next"><i class="bi bi-magic me-1"></i>Auto-fordel næste</button>' + '<button class="btn btn-sm btn-outline-primary" data-boss-action="open_unassigned"><i class="bi bi-person-x me-1"></i>Alle ufordelte</button>' +
'<button class="btn btn-sm btn-outline-primary" data-boss-action="open_unassigned"><i class="bi bi-person-x me-1"></i>Fordel ufordelte</button>' +
'<button class="btn btn-sm btn-outline-danger" data-boss-action="open_escalations"><i class="bi bi-exclamation-octagon me-1"></i>Se eskaleringer</button>' + '<button class="btn btn-sm btn-outline-danger" data-boss-action="open_escalations"><i class="bi bi-exclamation-octagon me-1"></i>Se eskaleringer</button>' +
'<button class="btn btn-sm btn-outline-secondary" data-boss-action="open_team"><i class="bi bi-people me-1"></i>Team-overblik</button>' + '<button class="btn btn-sm btn-outline-secondary" data-boss-action="open_team"><i class="bi bi-people me-1"></i>Team-overblik</button>' +
'</div>' '</div>'
@ -1059,15 +1065,23 @@
} }
if (unassigned.length > 0) { if (unassigned.length > 0) {
out.push('<div class="small text-muted mt-3 mb-1">Ufordelte sager</div>'); out.push('<div class="d-flex align-items-center justify-content-between mt-3 mb-1"><div class="small text-muted">Supportkø uden ansvarlig</div><span class="badge text-bg-warning">' + Number(stats.support_unassigned || 0) + '</span></div>');
unassigned.slice(0, 4).forEach(function (c) { unassigned.slice(0, 6).forEach(function (c) {
const ageHours = Math.floor(Number(c.age_seconds || 0) / 3600);
const ageLabel = ageHours >= 24 ? Math.floor(ageHours / 24) + 'd i kø' : Math.max(ageHours, 0) + 't i kø';
out.push( out.push(
'<div class="d-flex justify-content-between align-items-center border rounded p-2">' + '<div class="bb-support-queue-card">' +
'<div><strong>' + esc(c.title || 'Sag') + '</strong><div class="small text-muted">Prioritet: ' + esc(c.priority || 'normal') + '</div></div>' + '<div class="bb-support-queue-main"><strong>' + esc(c.title || 'Sag') + '</strong><div class="small text-muted">' + esc(c.customer_name || 'Ingen kunde') + ' · ' + ageLabel + ' · ' + esc(c.priority || 'normal') + '</div></div>' +
'<button class="btn btn-sm btn-outline-warning" data-boss-action="open_case" data-case-id="' + Number(c.id || 0) + '">Åbn</button>' + '<div class="bb-support-queue-actions">' +
'<select class="form-select form-select-sm" data-boss-assignee-for="' + Number(c.id || 0) + '"><option value="">Vælg medarbejder…</option>' + technicianOptions + '</select>' +
'<button class="btn btn-sm btn-primary" data-boss-action="assign_case_to_owner" data-case-id="' + Number(c.id || 0) + '"' + (technicianOptions ? '' : ' disabled') + '>Tildel</button>' +
'<button class="btn btn-sm btn-outline-secondary" data-boss-action="open_case" data-case-id="' + Number(c.id || 0) + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i></button>' +
'</div>' +
'</div>' '</div>'
); );
}); });
} else {
out.push('<div class="bb-boss-empty mt-3"><i class="bi bi-check-circle-fill"></i> Supportkøen er fordelt.</div>');
} }
return out; return out;
@ -1186,7 +1200,7 @@
messages: 'Beskeder', messages: 'Beskeder',
tasks: 'Opgaver', tasks: 'Opgaver',
notes: 'Noter', notes: 'Noter',
boss: 'Chef Dashboard' boss: 'Sagsfordeling'
}; };
const iconByKey = { const iconByKey = {
@ -2718,6 +2732,42 @@
const bossAction = btn.getAttribute('data-boss-action'); const bossAction = btn.getAttribute('data-boss-action');
if (bossAction) { if (bossAction) {
if (bossAction === 'assign_case_to_owner') {
const caseId = Number(btn.getAttribute('data-case-id') || 0);
const assigneeSelect = document.querySelector('[data-boss-assignee-for="' + caseId + '"]');
const ownerId = Number(assigneeSelect && assigneeSelect.value ? assigneeSelect.value : 0);
if (caseId <= 0 || ownerId <= 0) {
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-info-circle me-1 text-warning"></i> Vælg en medarbejder før sagen tildeles.';
return;
}
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
fetch('/api/v1/bottom-bar/boss/assign-case', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ case_id: caseId, assignee_user_id: ownerId })
})
.then(async r => {
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.detail || 'Kunne ikke tildele sag');
return body;
})
.then(data => {
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> ' + escapeHtml(data.message || 'Sagen blev tildelt.');
return fetchBottomBarState();
})
.then(applyState)
.catch(err => {
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i> ' + escapeHtml(err.message || 'Fejl ved tildeling');
})
.finally(() => { btn.disabled = false; btn.innerHTML = originalHtml; });
return;
}
if (bossAction === 'assign_next_to_owner') { if (bossAction === 'assign_next_to_owner') {
const ownerId = Number(btn.getAttribute('data-owner-id') || 0); const ownerId = Number(btn.getAttribute('data-owner-id') || 0);
if (ownerId <= 0) { if (ownerId <= 0) {