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-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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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')));
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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.';
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user