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:
parent
56cc1bdcc4
commit
533a652337
@ -1043,18 +1043,6 @@
|
||||
<span class="info-label">Standard timepris</span>
|
||||
<span class="info-value" id="standardHourlyRate">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Særlig fragtpris</span>
|
||||
<span class="info-value" id="specialFreightPrice">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Leverandørservice</span>
|
||||
<span class="info-value" id="supplierServiceEnrolled">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Faktureringsgebyr</span>
|
||||
<span class="info-value" id="invoiceFeeAmount">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Spærret</span>
|
||||
<span class="info-value" id="barred">-</span>
|
||||
@ -1983,25 +1971,6 @@
|
||||
<input type="number" class="form-control" id="editStandardHourlyRate" min="0" step="0.01">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="editSpecialFreightPrice" class="form-label">Særlig fragtpris (DKK)</label>
|
||||
<input type="number" class="form-control" id="editSpecialFreightPrice" min="0" step="0.01">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="editInvoiceFeeAmount" class="form-label">Faktureringsgebyr (DKK)</label>
|
||||
<input type="number" class="form-control" id="editInvoiceFeeAmount" min="0" step="0.01">
|
||||
<div class="form-text">Sæt 0 for at slå gebyr fra på ordren.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 d-flex align-items-end">
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="editSupplierServiceEnrolled">
|
||||
<label class="form-check-label" for="editSupplierServiceEnrolled">
|
||||
Tilmeldt leverandørservice
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="col-12 mt-4">
|
||||
@ -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
|
||||
? '<span class="badge bg-success">Tilmeldt</span>'
|
||||
: '<span class="badge bg-secondary">Ikke tilmeldt</span>';
|
||||
document.getElementById('invoiceFeeAmount').textContent = Number(invoiceFee) === 0
|
||||
? '0,00 DKK (deaktiveret)'
|
||||
: `${Number(invoiceFee).toFixed(2)} DKK`;
|
||||
document.getElementById('barred').innerHTML = customer.barred
|
||||
? '<span class="badge bg-danger">Ja</span>'
|
||||
: '<span class="badge bg-success">Nej</span>';
|
||||
@ -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
|
||||
};
|
||||
|
||||
|
||||
@ -795,6 +795,11 @@
|
||||
<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'}
|
||||
</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>
|
||||
${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 = '<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]');
|
||||
if (!row) return;
|
||||
await loadJobDetails(Number(row.getAttribute('data-job-row')));
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) => `<option value="${index}">${port.port_number}</option>`)
|
||||
.map((port, index) => `<option value="${index}">Port ${port.port_number} — ${bulkSwitchPortStatus(port)}</option>`)
|
||||
.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
|
||||
? `<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
|
||||
? `<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
|
||||
? 'Det valgte område går ud over de oprettede switch-porte.'
|
||||
: 'Vælg et gyldigt portområde.';
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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 @@
|
||||
<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>
|
||||
<!-- 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 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>
|
||||
@ -1450,7 +1489,7 @@ if (bmcOriginalFetch) {
|
||||
<script src="/static/js/telefoni.js?v=2.4"></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/bottom-bar.js?v=2.43"></script>
|
||||
<script src="/static/js/bottom-bar.js?v=2.44"></script>
|
||||
<script>
|
||||
// Dark Mode Toggle Logic
|
||||
window.BMC_CAN_CLICK_TO_CALL = true;
|
||||
|
||||
@ -991,17 +991,23 @@
|
||||
const techniciansToday = Array.isArray(boss.technicians_today) ? boss.technicians_today : [];
|
||||
const escalations = Array.isArray(boss.escalations) ? boss.escalations : [];
|
||||
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 = [
|
||||
'<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">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>',
|
||||
'<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>Fordel ufordelte</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-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>' +
|
||||
'</div>'
|
||||
@ -1059,15 +1065,23 @@
|
||||
}
|
||||
|
||||
if (unassigned.length > 0) {
|
||||
out.push('<div class="small text-muted mt-3 mb-1">Ufordelte sager</div>');
|
||||
unassigned.slice(0, 4).forEach(function (c) {
|
||||
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, 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(
|
||||
'<div class="d-flex justify-content-between align-items-center border rounded p-2">' +
|
||||
'<div><strong>' + esc(c.title || 'Sag') + '</strong><div class="small text-muted">Prioritet: ' + 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-card">' +
|
||||
'<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>' +
|
||||
'<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>'
|
||||
);
|
||||
});
|
||||
} 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;
|
||||
@ -1186,7 +1200,7 @@
|
||||
messages: 'Beskeder',
|
||||
tasks: 'Opgaver',
|
||||
notes: 'Noter',
|
||||
boss: 'Chef Dashboard'
|
||||
boss: 'Sagsfordeling'
|
||||
};
|
||||
|
||||
const iconByKey = {
|
||||
@ -2718,6 +2732,42 @@
|
||||
|
||||
const bossAction = btn.getAttribute('data-boss-action');
|
||||
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') {
|
||||
const ownerId = Number(btn.getAttribute('data-owner-id') || 0);
|
||||
if (ownerId <= 0) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user