diff --git a/app/auth/backend/router.py b/app/auth/backend/router.py index cfba002..cba9c85 100644 --- a/app/auth/backend/router.py +++ b/app/auth/backend/router.py @@ -217,6 +217,22 @@ class UserProfileUpdate(BaseModel): phone: Optional[str] = None title: Optional[str] = None anydesk_id: Optional[str] = None + default_case_type: Optional[str] = None + + +def _allowed_case_types() -> list[str]: + fallback = ["ticket", "pipeline", "opgave", "ordre", "projekt", "service"] + try: + rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or [] + if rows: + import json + configured = json.loads(rows[0].get("value") or "[]") + values = [str(value).strip().lower() for value in configured if str(value).strip()] + if values: + return list(dict.fromkeys(values + (["pipeline"] if "pipeline" not in values else []))) + except Exception: + logger.warning("Could not load configured case types for profile preference", exc_info=True) + return fallback @router.get("/me/profile") @@ -228,7 +244,18 @@ async def get_my_profile(current_user: dict = Depends(get_current_user)): ) if not rows: raise HTTPException(status_code=404, detail="User not found") - return dict(rows[0]) + profile = dict(rows[0]) + profile["default_case_type"] = "ticket" + try: + preference = execute_query( + "SELECT default_case_type FROM user_sag_create_preferences WHERE user_id = %s", + (current_user["id"],), + ) or [] + if preference: + profile["default_case_type"] = preference[0].get("default_case_type") or "ticket" + except Exception: + logger.warning("Sag create preferences table not available yet") + return profile @router.patch("/me/profile") @@ -253,16 +280,33 @@ async def update_my_profile( fields.append("anydesk_id = %s") values.append(payload.anydesk_id.strip() or None) - if not fields: + if payload.default_case_type is not None: + default_case_type = payload.default_case_type.strip().lower() or "ticket" + if default_case_type not in _allowed_case_types(): + raise HTTPException(status_code=400, detail="Ukendt sagstype") + try: + execute_query( + """ + INSERT INTO user_sag_create_preferences (user_id, default_case_type, updated_at) + VALUES (%s, %s, NOW()) + ON CONFLICT (user_id) DO UPDATE + SET default_case_type = EXCLUDED.default_case_type, updated_at = NOW() + """, + (current_user["id"], default_case_type), + ) + except Exception as exc: + raise HTTPException(status_code=409, detail="Profilindstillingen er ikke klar. Kør migration 214 først.") from exc + + if not fields and payload.default_case_type is None: raise HTTPException(status_code=400, detail="No fields to update") - fields.append("updated_at = NOW()") - values.append(current_user["id"]) - - execute_query( - f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s", - tuple(values) - ) + if fields: + fields.append("updated_at = NOW()") + values.append(current_user["id"]) + execute_query( + f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s", + tuple(values) + ) return {"message": "Profil opdateret"} diff --git a/app/contacts/frontend/contact_detail.html b/app/contacts/frontend/contact_detail.html index f61d04a..ba5b26c 100644 --- a/app/contacts/frontend/contact_detail.html +++ b/app/contacts/frontend/contact_detail.html @@ -1383,7 +1383,7 @@ async function loadContactOpportunities() { ${escapeHtml(stage)} ${probability} - + diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py index 7435435..dc6d96b 100644 --- a/app/customers/backend/router.py +++ b/app/customers/backend/router.py @@ -1220,6 +1220,145 @@ async def get_customer_contacts(customer_id: int): return rows or [] +@router.get("/customers/{customer_id}/economic-invoices") +async def get_customer_economic_invoices(customer_id: int, limit: int = Query(default=100, ge=1, le=500)): + """Get imported e-conomic invoices for a customer from invoice_error_finder staging data.""" + customer = execute_query_single( + "SELECT id, name, economic_customer_number FROM customers WHERE id = %s", + (customer_id,), + ) + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + economic_customer_number = customer.get("economic_customer_number") + if not economic_customer_number: + return { + "customer_id": customer_id, + "customer_name": customer.get("name"), + "economic_customer_number": None, + "items": [], + } + + rows = execute_query( + """ + WITH ranked_invoices AS ( + SELECT + inv.id, + inv.source_invoice_number, + inv.invoice_date, + inv.due_date, + inv.total_amount, + inv.net_amount, + inv.vat_amount, + inv.currency, + inv.source_type, + COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading, + NULLIF( + CONCAT_WS( + E'\n', + NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''), + NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '') + ), + '' + ) AS note_text, + CASE inv.source_type + WHEN 'paid' THEN 1 + WHEN 'booked' THEN 2 + WHEN 'unpaid' THEN 3 + WHEN 'draft' THEN 4 + ELSE 9 + END AS source_rank + FROM invoice_error_finder_economic_invoices inv + WHERE inv.customer_number = %s + ), + selected_invoices AS ( + SELECT DISTINCT ON (source_invoice_number) + id, + source_invoice_number, + invoice_date, + due_date, + total_amount, + net_amount, + vat_amount, + currency, + source_type, + heading, + note_text + FROM ranked_invoices + ORDER BY source_invoice_number, source_rank, invoice_date DESC, id DESC + ) + SELECT + si.id AS invoice_id, + si.source_invoice_number, + si.invoice_date, + si.due_date, + si.total_amount, + si.net_amount, + si.vat_amount, + si.currency, + si.source_type, + si.heading, + si.note_text, + line.line_number, + line.product_number, + line.product_name, + line.description, + line.quantity, + line.unit_price, + line.line_net_amount + FROM selected_invoices si + LEFT JOIN invoice_error_finder_economic_invoice_lines line + ON line.invoice_id = si.id + ORDER BY si.invoice_date DESC NULLS LAST, si.source_invoice_number DESC, line.line_number ASC + LIMIT %s + """, + (economic_customer_number, limit * 25), + ) or [] + + invoices: List[Dict[str, Any]] = [] + invoices_by_id: Dict[int, Dict[str, Any]] = {} + for row in rows: + invoice_id = row.get("invoice_id") + if invoice_id is None: + continue + if invoice_id not in invoices_by_id: + payload = { + "invoice_id": invoice_id, + "invoice_number": row.get("source_invoice_number"), + "invoice_date": row["invoice_date"].isoformat() if row.get("invoice_date") else None, + "due_date": row["due_date"].isoformat() if row.get("due_date") else None, + "total_amount": float(row.get("total_amount") or 0), + "net_amount": float(row.get("net_amount") or 0), + "vat_amount": float(row.get("vat_amount") or 0), + "currency": row.get("currency") or "DKK", + "source_type": row.get("source_type"), + "heading": row.get("heading") or None, + "note_text": row.get("note_text") or None, + "lines": [], + } + invoices_by_id[invoice_id] = payload + invoices.append(payload) + if row.get("line_number") is not None: + invoices_by_id[invoice_id]["lines"].append( + { + "line_number": int(row.get("line_number") or 0), + "product_number": row.get("product_number"), + "product_name": row.get("product_name"), + "description": row.get("description"), + "quantity": float(row.get("quantity") or 0), + "unit_price": float(row.get("unit_price") or 0), + "line_net_amount": float(row.get("line_net_amount") or 0), + } + ) + + return { + "customer_id": customer_id, + "customer_name": customer.get("name"), + "economic_customer_number": economic_customer_number, + "items": invoices[:limit], + } + + @router.get("/customers/{customer_id}/kontakt") async def get_customer_kontakt_history(customer_id: int, limit: int = Query(default=300, ge=1, le=2000)): """Get unified contact communication history (calls + SMS) for all company contacts.""" diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index 60fc59d..f7af305 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -259,6 +259,253 @@ .subscription-column { min-height: 200px; } + + .customer-invoice-list { + display: block; + } + + .customer-invoice-shell { + background: var(--bg-card); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 12px; + overflow: hidden; + } + + .customer-invoice-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + background: rgba(15, 76, 117, 0.02); + } + + .customer-invoice-toolbar-copy { + min-width: 0; + } + + .customer-invoice-toolbar-title { + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 0.2rem; + } + + .customer-invoice-toolbar-subtitle { + color: var(--text-secondary); + font-size: 0.88rem; + } + + .customer-invoice-summary { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + justify-content: flex-end; + } + + .customer-invoice-summary-chip { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.75rem; + border-radius: 999px; + background: rgba(0, 0, 0, 0.04); + color: var(--text-primary); + font-size: 0.8rem; + font-weight: 700; + line-height: 1; + } + + .customer-invoice-month-group + .customer-invoice-month-group { + border-top: 1px solid rgba(0, 0, 0, 0.08); + } + + .customer-invoice-month-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.9rem 1.25rem; + background: rgba(0, 0, 0, 0.015); + } + + .customer-invoice-month-label { + color: var(--text-primary); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .customer-invoice-month-total { + color: var(--text-secondary); + font-size: 0.82rem; + font-weight: 700; + } + + .customer-invoice-table { + margin-bottom: 0; + } + + .customer-invoice-table thead th { + background: rgba(15, 76, 117, 0.04); + color: var(--text-secondary); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + white-space: nowrap; + } + + .customer-invoice-row td { + vertical-align: middle; + } + + .customer-invoice-row:hover { + background: rgba(15, 76, 117, 0.025); + } + + .customer-invoice-number { + display: inline-flex; + align-items: center; + gap: 0.6rem; + font-weight: 700; + color: var(--text-primary); + } + + .customer-invoice-period-cell { + max-width: 340px; + } + + .customer-invoice-period-text { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-primary); + font-weight: 500; + } + + .customer-invoice-period-sub { + display: block; + color: var(--text-secondary); + font-size: 0.8rem; + margin-top: 0.15rem; + } + + .customer-invoice-status { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 76px; + padding: 0.38rem 0.7rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + } + + .customer-invoice-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + flex-wrap: wrap; + } + + .customer-invoice-open, + .customer-invoice-toggle { + border-radius: 999px; + padding-inline: 0.8rem; + font-size: 0.78rem; + white-space: nowrap; + } + + .customer-invoice-expanded-row td { + background: rgba(15, 76, 117, 0.025); + padding: 0; + border-top: 0; + } + + .customer-invoice-expanded { + padding: 1rem 1.25rem 1.1rem; + } + + .customer-invoice-note-panel { + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 10px; + background: #fff; + padding: 0.8rem 0.95rem; + margin-bottom: 1rem; + font-size: 0.9rem; + } + + .customer-invoice-totals { + border-top: 1px solid rgba(0, 0, 0, 0.08); + margin-top: 1rem; + padding-top: 0.85rem; + font-size: 0.92rem; + } + + .matrix-cell-button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 0; + background: transparent; + padding: 0; + margin-top: 0.3rem; + color: var(--accent); + font-size: 0.78rem; + font-weight: 600; + text-decoration: underline; + } + + .matrix-cell-button:hover { + color: #0b3b5a; + } + + .invoice-detail-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.85rem; + margin-bottom: 1rem; + } + + .invoice-detail-card { + border: 1px solid rgba(15, 76, 117, 0.12); + border-radius: 12px; + background: rgba(15, 76, 117, 0.04); + padding: 0.85rem 1rem; + } + + .invoice-detail-card .label { + display: block; + color: var(--text-secondary); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 0.25rem; + } + + .invoice-detail-card .value { + color: var(--text-primary); + font-weight: 700; + } + + @media (max-width: 991.98px) { + .customer-invoice-toolbar { + flex-direction: column; + align-items: flex-start; + } + + .customer-invoice-summary { + justify-content: flex-start; + } + } .column-header { position: sticky; @@ -990,9 +1237,27 @@
-
Fakturaer
-
- Fakturamodul kommer snart... +
+
+
Fakturaer
+ Importerede e-conomic-fakturaer for kunden +
+ +
+
+ +
+ + 0 +
+
+
+ Åbn fanen for at indlæse fakturaer...
@@ -1088,9 +1353,9 @@
Abonnements-matrix - (fra e-conomic) + (fra importerede e-conomic-fakturaer)
-
@@ -2007,12 +2272,29 @@
- - - + + + + + + {% endblock %} {% block extra_js %} @@ -2032,6 +2314,9 @@ let pipelineStages = []; let allTagsCache = []; let customerKontaktItems = []; let customerKontaktFilter = 'all'; +let customerInvoicesLoaded = false; +let customerInvoicesData = []; +let customerInvoiceSearchTerm = ''; let eventListenersAdded = false; @@ -2157,6 +2442,13 @@ document.addEventListener('DOMContentLoaded', () => { }, { once: false }); } + const invoicesTab = document.querySelector('a[href="#invoices"]'); + if (invoicesTab) { + invoicesTab.addEventListener('shown.bs.tab', () => { + loadCustomerInvoices(); + }, { once: false }); + } + if (window.location.hash) { const hashTab = document.querySelector(`a[data-bs-toggle="tab"][href="${window.location.hash}"]`); if (hashTab && window.bootstrap?.Tab) { @@ -3953,7 +4245,7 @@ function renderCustomerPipeline(opportunities) { ${o.probability || 0}% - @@ -4472,6 +4764,349 @@ function formatCurrency(value, currency) { return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num); } +function renderCustomerInvoiceLineRows(lines) { + if (!Array.isArray(lines) || lines.length === 0) { + return '
Ingen fakturalinjer fundet
'; + } + + return ` +
+ + + + + + + + + + + + + ${lines.map(line => { + const description = line.description || line.product_name || '-'; + return ` + + + + + + + + + `; + }).join('')} + +
LinjeVarenrBeskrivelseAntalPrisBeløb
${Number(line.line_number || 0).toLocaleString('da-DK')}${escapeHtml(line.product_number || '-')}${escapeHtml(description)}${Number(line.quantity || 0).toLocaleString('da-DK')}${formatCurrency(line.unit_price, 'DKK')}${formatCurrency(line.line_net_amount, 'DKK')}
+
+ `; +} + +function renderCustomerInvoiceCards(invoices) { + if (!Array.isArray(invoices) || invoices.length === 0) { + return '
Ingen importerede e-conomic-fakturaer fundet for denne kunde
'; + } + + const grouped = {}; + invoices.forEach((invoice) => { + const key = (invoice.invoice_date || '').slice(0, 7) || 'unknown'; + if (!grouped[key]) grouped[key] = []; + grouped[key].push(invoice); + }); + + const summaryTotal = invoices.reduce((sum, invoice) => sum + parseFloat(invoice.total_amount || 0), 0); + const monthKeys = Object.keys(grouped).sort().reverse(); + + const groupsHtml = monthKeys.map((monthKey) => { + const monthInvoices = grouped[monthKey] || []; + const monthTotal = monthInvoices.reduce((sum, invoice) => sum + parseFloat(invoice.total_amount || 0), 0); + const monthLabel = formatInvoiceMonthLabel(monthKey); + + const rows = monthInvoices.map((invoice, idx) => { + const itemId = `customer-economic-invoice-${monthKey}-${idx}`; + const status = invoice.source_type || 'booked'; + const periodText = invoice.heading || invoice.note_text || 'Ingen periodetekst'; + const lineCount = Array.isArray(invoice.lines) ? invoice.lines.length : 0; + const detailLabel = lineCount > 0 ? `Vis linjer (${lineCount})` : 'Vis detaljer'; + + return ` + + + + + + ${escapeHtml(periodText)} + ${lineCount} linjer + + ${invoice.invoice_date ? escapeHtml(formatDate(invoice.invoice_date)) : '-'} + ${invoice.due_date ? escapeHtml(formatDate(invoice.due_date)) : '-'} + ${formatCurrency(invoice.total_amount, invoice.currency || 'DKK')} + ${escapeHtml(status)} + +
+ + +
+ + + + + + + + `; + }).join(''); + + return ` +
+
+
${escapeHtml(monthLabel)}
+
${monthInvoices.length} fakturaer · ${formatCurrency(monthTotal, monthInvoices[0]?.currency || 'DKK')}
+
+
+ + + + + + + + + + + + + + ${rows} + +
FakturaPeriodeDatoForfaldBeløbStatusHandling
+
+
+ `; + }).join(''); + + return ` +
+
+
+
Fakturaoversigt
+
Importerede e-conomic-fakturaer vist som en almindelig oversigt med detaljer pr. faktura
+
+
+ ${invoices.length} fakturaer + ${monthKeys.length} måneder + ${formatCurrency(summaryTotal, invoices[0]?.currency || 'DKK')} +
+
+
${groupsHtml}
+
+ `; +} + +function formatInvoiceMonthLabel(yearMonth) { + if (!yearMonth || yearMonth === 'unknown') return 'Uden dato'; + try { + const date = new Date(`${yearMonth}-01`); + return date.toLocaleDateString('da-DK', { month: 'long', year: 'numeric' }); + } catch { + return yearMonth; + } +} + +function getFilteredCustomerInvoices() { + const term = String(customerInvoiceSearchTerm || '').trim().toLowerCase(); + if (!term) return customerInvoicesData; + + return customerInvoicesData.filter((invoice) => { + const haystack = [ + invoice.invoice_number, + invoice.invoice_date, + invoice.due_date, + invoice.heading, + invoice.note_text, + ...(Array.isArray(invoice.lines) ? invoice.lines.flatMap((line) => [ + line.product_number, + line.product_name, + line.description + ]) : []) + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return haystack.includes(term); + }); +} + +function renderFilteredCustomerInvoices() { + const container = document.getElementById('customerInvoicesContainer'); + const countBadge = document.getElementById('customerInvoiceResultCount'); + if (!container) return; + + const filteredInvoices = getFilteredCustomerInvoices(); + container.innerHTML = renderCustomerInvoiceCards(filteredInvoices); + + if (countBadge) { + countBadge.textContent = String(filteredInvoices.length); + } +} + +function filterCustomerInvoices(value) { + customerInvoiceSearchTerm = String(value || ''); + renderFilteredCustomerInvoices(); +} + +function clearCustomerInvoiceSearch() { + const input = document.getElementById('customerInvoiceSearchInput'); + customerInvoiceSearchTerm = ''; + if (input) { + input.value = ''; + input.focus(); + } + renderFilteredCustomerInvoices(); +} + +async function loadCustomerInvoices(force = false) { + const container = document.getElementById('customerInvoicesContainer'); + const countBadge = document.getElementById('customerInvoiceResultCount'); + if (!container) return; + if (customerInvoicesLoaded && !force) return; + + container.innerHTML = ` +
+
+
Indlæser fakturaer...
+
+ `; + + try { + const data = await fetchCustomerInvoices(force); + + if (!data.economic_customer_number) { + container.innerHTML = '
Kunden har ikke et e-conomic kundenummer i BMC Hub endnu.
'; + if (countBadge) countBadge.textContent = '0'; + customerInvoicesLoaded = true; + return; + } + + renderFilteredCustomerInvoices(); + customerInvoicesLoaded = true; + } catch (error) { + if (countBadge) countBadge.textContent = '0'; + container.innerHTML = `
${escapeHtml(error.message || 'Kunne ikke hente fakturaer')}
`; + } +} + +async function fetchCustomerInvoices(force = false) { + if (customerInvoicesData.length > 0 && !force) { + return { + customer_id: customerId, + items: customerInvoicesData, + economic_customer_number: customerData?.economic_customer_number || true + }; + } + + const response = await fetch(`/api/v1/customers/${customerId}/economic-invoices`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.detail || 'Kunne ikke hente fakturaer'); + } + + customerInvoicesData = Array.isArray(data.items) ? data.items : []; + return data; +} + +function renderInvoiceDetailModal(invoice) { + const linesHtml = renderCustomerInvoiceLineRows(invoice.lines || []); + const notePanel = (invoice.heading || invoice.note_text) ? ` +
+ ${invoice.heading ? `
${escapeHtml(invoice.heading)}
` : ''} + ${invoice.note_text ? `
${escapeHtml(invoice.note_text)}
` : ''} +
+ ` : ''; + + return ` +
+
+ Fakturanummer + ${escapeHtml(invoice.invoice_number || '-')} +
+
+ Status + ${escapeHtml(invoice.source_type || '-')} +
+
+ Fakturadato + ${escapeHtml(formatDate(invoice.invoice_date) || '-')} +
+
+ Forfald + ${escapeHtml(formatDate(invoice.due_date) || '-')} +
+
+ Netto + ${escapeHtml(formatCurrency(invoice.net_amount, invoice.currency || 'DKK'))} +
+
+ Total + ${escapeHtml(formatCurrency(invoice.total_amount, invoice.currency || 'DKK'))} +
+
+ ${notePanel} + ${linesHtml} + `; +} + +async function openCustomerInvoiceDetail(invoiceNumber) { + const body = document.getElementById('customerInvoiceDetailBody'); + const modalElement = document.getElementById('customerInvoiceDetailModal'); + if (!body || !modalElement) return; + + body.innerHTML = '
'; + const modal = new bootstrap.Modal(modalElement); + modal.show(); + + try { + await fetchCustomerInvoices(false); + const matches = customerInvoicesData.filter(invoice => String(invoice.invoice_number || '') === String(invoiceNumber || '')); + + if (!matches.length) { + body.innerHTML = '
Kunne ikke finde fakturaen i de importerede kundedata.
'; + return; + } + + body.innerHTML = matches.map(renderInvoiceDetailModal).join('
'); + } catch (error) { + body.innerHTML = `
${escapeHtml(error.message || 'Kunne ikke hente faktura')}
`; + } +} + async function loadActivity() { const container = document.getElementById('activityContainer'); container.innerHTML = '
'; @@ -6166,11 +6801,15 @@ function renderBillingMatrix(matrix) { const amount = cell.amount || 0; const statusBadge = getStatusBadge(cell.status); const tooltip = cell.period_label ? ` title="${cell.period_label}${cell.invoice_number ? ' • ' + cell.invoice_number : ''}"` : ''; + const detailButton = cell.invoice_number + ? `` + : ''; return `
${formatDKK(amount)}
${statusBadge}
+ ${detailButton}
`; }).join(''); diff --git a/app/modules/internet_connections/backend/router.py b/app/modules/internet_connections/backend/router.py index 1581408..fe69890 100644 --- a/app/modules/internet_connections/backend/router.py +++ b/app/modules/internet_connections/backend/router.py @@ -483,7 +483,14 @@ async def _build_customer_document_hits(customer_id: int, customer_name: str, qu title_hit = _count_term_hits(title, terms["query_terms"]) if has_query: - if query_hits == 0 and explicit_entity_hit == 0 and phrase_hit == 0 and title_hit == 0: + # A block is only relevant when it contains every word from the user's + # search. A query such as "sales management" must not return a block + # containing just one of the two words. + all_query_terms_match = all( + term in _normalize_text_for_match(searchable) + for term in terms["query_terms"] + ) + if not all_query_terms_match: continue elif customer_hits == 0 and query_hits == 0 and explicit_entity_hit == 0: continue @@ -3106,7 +3113,9 @@ async def get_migration_wizard_v2_context( if summary_source_parts: summary_input = ( f"Kunde: {customer_name}\n" - f"Sporgsmaal: {query or 'Vis relevant historik om internetforbindelser, adresser og gamle noter'}\n\n" + f"Sporgsmaal: {query or 'Vis relevant historik om internetforbindelser, adresser og gamle noter'}\n" + "VIGTIGT: Find altid WAN IP-adressen. Skriv den tydeligt i overblikket, " + "eller skriv eksplicit at ingen WAN IP-adresse blev fundet.\n\n" + "\n\n".join(summary_source_parts) ) ai_summary = await ollama_service.generate_summary(summary_input) @@ -3120,3 +3129,35 @@ async def get_migration_wizard_v2_context( "invoice_hits": invoice_hits, "ai_summary": ai_summary, } + + +@router.get("/internet-connections/customer-documents/segments/{segment_id}") +async def get_customer_document_segment(segment_id: int): + """Return the complete, indexed text block for the migration wizard.""" + row = execute_query_single( + """ + SELECT + seg.id AS segment_id, + seg.document_id, + seg.block_index, + seg.block_title AS title, + seg.content, + doc.original_filename + FROM internet_connections_customer_document_segments seg + JOIN internet_connections_customer_documents doc ON doc.id = seg.document_id + WHERE seg.id = %s + AND doc.deleted_at IS NULL + """, + (segment_id,), + ) + if not row: + raise HTTPException(status_code=404, detail="Text block not found") + + return { + "segment_id": int(row["segment_id"]), + "document_id": int(row["document_id"]), + "block_index": int(row.get("block_index") or 0), + "title": row.get("title") or f"Blok {int(row.get('block_index') or 0) + 1}", + "original_filename": row.get("original_filename") or "Tekstfil", + "content": str(row.get("content") or ""), + } diff --git a/app/modules/internet_connections/templates/migration_wizard_v2.html b/app/modules/internet_connections/templates/migration_wizard_v2.html index 65d6beb..5b0b4b0 100644 --- a/app/modules/internet_connections/templates/migration_wizard_v2.html +++ b/app/modules/internet_connections/templates/migration_wizard_v2.html @@ -107,6 +107,28 @@ white-space: pre-wrap; } + .wiz-segment-button { + width: 100%; + border: 0; + padding: 0; + text-align: left; + color: inherit; + background: transparent; + } + + .wiz-segment-button:hover .wiz-card, + .wiz-segment-button:focus-visible .wiz-card { + border-color: rgba(15, 76, 117, 0.45); + box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.1); + } + + .wiz-full-block { + white-space: pre-wrap; + max-height: 60vh; + overflow: auto; + margin: 0; + } + @media (max-width: 991px) { .wiz-grid { grid-template-columns: 1fr; @@ -256,11 +278,27 @@ + + {% endblock %} {% block extra_js %} {% endblock %} diff --git a/app/modules/locations/backend/router.py b/app/modules/locations/backend/router.py index bfd9eb8..52a7991 100644 --- a/app/modules/locations/backend/router.py +++ b/app/modules/locations/backend/router.py @@ -792,6 +792,30 @@ async def update_location(id: int, data: LocationUpdate): status_code=400, detail="parent_location_id does not exist" ) + descendant_check = execute_query( + """ + WITH RECURSIVE descendants AS ( + SELECT id + FROM locations_locations + WHERE parent_location_id = %s AND deleted_at IS NULL + + UNION ALL + + SELECT l.id + FROM locations_locations l + JOIN descendants d ON l.parent_location_id = d.id + WHERE l.deleted_at IS NULL + ) + SELECT id FROM descendants WHERE id = %s LIMIT 1 + """, + (id, value), + ) + if descendant_check: + logger.warning("⚠️ parent_location_id cannot reference a descendant") + raise HTTPException( + status_code=400, + detail="parent_location_id cannot reference a descendant" + ) if key == 'customer_id': customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL" customer = execute_query(customer_query, (value,)) diff --git a/app/modules/locations/frontend/views.py b/app/modules/locations/frontend/views.py index 65b61ed..553fc5c 100644 --- a/app/modules/locations/frontend/views.py +++ b/app/modules/locations/frontend/views.py @@ -57,6 +57,100 @@ LOCATION_TYPES = [ {"value": "vehicle", "label": "Køretøj"}, ] +LOCATION_TYPE_LABELS = { + "kompleks": "Kompleks", + "bygning": "Bygning", + "etage": "Etage", + "customer_site": "Kundesite", + "rum": "Rum", + "kantine": "Kantine", + "moedelokale": "Mødelokale", + "vehicle": "Køretøj", +} + + +def get_location_type_label(location_type: Optional[str]) -> str: + return LOCATION_TYPE_LABELS.get(location_type or "", location_type or "Ukendt") + + +def get_parent_location_choices(exclude_id: Optional[int] = None) -> list[dict]: + exclude_ids = [] + if exclude_id is not None: + exclude_tree = execute_query( + """ + WITH RECURSIVE descendants AS ( + SELECT id + FROM locations_locations + WHERE id = %s + + UNION ALL + + SELECT l.id + FROM locations_locations l + JOIN descendants d ON l.parent_location_id = d.id + WHERE l.deleted_at IS NULL + ) + SELECT id FROM descendants + """, + (exclude_id,), + ) + exclude_ids = [row["id"] for row in (exclude_tree or []) if row.get("id") is not None] + + parent_locations = execute_query( + """ + WITH RECURSIVE location_tree AS ( + SELECT + id, + name, + location_type, + parent_location_id, + customer_id, + is_active, + name::text AS hierarchy_path, + 0 AS depth + FROM locations_locations + WHERE deleted_at IS NULL AND parent_location_id IS NULL + + UNION ALL + + SELECT + l.id, + l.name, + l.location_type, + l.parent_location_id, + l.customer_id, + l.is_active, + (lt.hierarchy_path || ' > ' || l.name)::text AS hierarchy_path, + lt.depth + 1 AS depth + FROM locations_locations l + JOIN location_tree lt ON l.parent_location_id = lt.id + WHERE l.deleted_at IS NULL + ) + SELECT + id, + name, + location_type, + parent_location_id, + customer_id, + is_active, + hierarchy_path, + depth + FROM location_tree + WHERE is_active = true + ORDER BY hierarchy_path + LIMIT 2000 + """ + ) + + choices = [] + for row in parent_locations or []: + if row.get("id") in exclude_ids: + continue + row["type_label"] = get_location_type_label(row.get("location_type")) + row["display_name"] = f"{row.get('hierarchy_path')} ({row['type_label']})" + choices.append(row) + return choices + def render_template(template_name: str, **context) -> str: """ @@ -247,7 +341,10 @@ def list_locations_view( # ============================================================================ @router.get("/app/locations/create", response_class=HTMLResponse) -def create_location_view(): +def create_location_view( + parent_location_id: Optional[int] = Query(None, gt=0), + customer_id: Optional[int] = Query(None, gt=0), +): """ Render the location creation form. @@ -268,14 +365,11 @@ def create_location_view(): try: logger.info("🆕 Rendering create location form") - # Query parent locations - parent_locations = execute_query(""" - SELECT id, name, location_type - FROM locations_locations - WHERE deleted_at IS NULL AND is_active = true - ORDER BY name - LIMIT 1000 - """) + parent_locations = get_parent_location_choices() + selected_parent = next((row for row in parent_locations if row.get("id") == parent_location_id), None) + + if selected_parent and customer_id is None and selected_parent.get("customer_id") is not None: + customer_id = selected_parent.get("customer_id") # Query customers customers = execute_query(""" @@ -296,6 +390,9 @@ def create_location_view(): location_types=LOCATION_TYPES, parent_locations=parent_locations, customers=customers, + selected_parent_id=parent_location_id, + selected_customer_id=customer_id, + selected_parent=selected_parent, location=None, # No location data for create form ) @@ -321,13 +418,7 @@ def location_wizard_view(): try: logger.info("🧭 Rendering location wizard") - parent_locations = execute_query(""" - SELECT id, name, location_type - FROM locations_locations - WHERE deleted_at IS NULL AND is_active = true - ORDER BY name - LIMIT 1000 - """) + parent_locations = get_parent_location_choices() customers = execute_query(""" SELECT id, name, email, phone @@ -555,14 +646,11 @@ def edit_location_view(id: int = Path(..., gt=0)): location = location[0] # Get first result - # Query parent locations (exclude self) - parent_locations = execute_query(""" - SELECT id, name, location_type - FROM locations_locations - WHERE is_active = true AND id != %s - ORDER BY name - LIMIT 1000 - """, (id,)) + parent_locations = get_parent_location_choices(exclude_id=id) + selected_parent = next( + (row for row in parent_locations if row.get("id") == location.get("parent_location_id")), + None, + ) # Query customers customers = execute_query(""" @@ -585,6 +673,7 @@ def edit_location_view(id: int = Path(..., gt=0)): location_types=LOCATION_TYPES, parent_locations=parent_locations, customers=customers, + selected_parent=selected_parent, http_method="PATCH", # Pass actual HTTP method for form to use via JavaScript/hidden field ) diff --git a/app/modules/locations/templates/create.html b/app/modules/locations/templates/create.html index 7cfdb38..8bcd221 100644 --- a/app/modules/locations/templates/create.html +++ b/app/modules/locations/templates/create.html @@ -2,6 +2,40 @@ {% block title %}Opret lokation - BMC Hub{% endblock %} +{% block extra_css %} + +{% endblock %} + {% block content %}
@@ -57,33 +91,66 @@
-
- +
+
+
+ +
Vælg hurtigt, hvor lokationen skal ligge, og søg i hele træet.
+
+ {% if selected_parent %} + + Åbn valgt parent + + {% endif %} +
+ +
+ + +
+ +
-
Bruges til hierarki (fx Bygning → Etage → Rum).
+
Bruges til hierarki, fx Kompleks → Bygning → Etage → Rum.
+
+ +
+ {% if selected_parent %} +
Valgt overordnet lokation
+
{{ selected_parent.hierarchy_path }}
+
{{ selected_parent.type_label }}
+ {% else %} +
Lokationen oprettes i topniveau, indtil du vælger en overordnet lokation.
+ {% endif %} +
-
- - -
Valgfri – kan knyttes til alle typer.
-
+
+ + +
Hvis du vælger en parent med kunde, kan den forudfyldes automatisk.
+
@@ -185,12 +252,79 @@ document.addEventListener('DOMContentLoaded', function() { const submitBtn = document.getElementById('submitBtn'); const notesField = document.getElementById('notes'); const charCount = document.getElementById('charCount'); + const parentLocationSelect = document.getElementById('parentLocation'); + const parentLocationSearch = document.getElementById('parentLocationSearch'); + const customerSelect = document.getElementById('customerId'); + const parentSummary = document.getElementById('parentSummary'); // Character counter for notes notesField.addEventListener('input', function() { charCount.textContent = this.value.length; }); + function escapeHtml(value) { + return String(value || '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function updateParentSummary() { + const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex]; + const path = selectedOption?.dataset?.path || ''; + const type = selectedOption?.dataset?.type || ''; + + if (!selectedOption || !selectedOption.value) { + parentSummary.classList.add('empty'); + parentSummary.innerHTML = '
Lokationen oprettes i topniveau, indtil du vælger en overordnet lokation.
'; + return; + } + + parentSummary.classList.remove('empty'); + parentSummary.innerHTML = ` +
Valgt overordnet lokation
+
${escapeHtml(path)}
+
${escapeHtml(type)}
+ `; + } + + function filterParentLocations() { + const query = (parentLocationSearch.value || '').trim().toLowerCase(); + Array.from(parentLocationSelect.options).forEach((option, index) => { + if (index === 0) { + option.hidden = false; + return; + } + const haystack = `${option.text} ${option.dataset.path || ''} ${option.dataset.type || ''}`.toLowerCase(); + option.hidden = query ? !haystack.includes(query) : false; + }); + } + + if (parentLocationSearch) { + parentLocationSearch.addEventListener('input', filterParentLocations); + } + + if (parentLocationSelect) { + parentLocationSelect.addEventListener('change', function() { + const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex]; + const parentCustomerId = selectedOption?.dataset?.customerId; + if ((!customerSelect.value || customerSelect.dataset.autofilled === 'true') && parentCustomerId) { + customerSelect.value = parentCustomerId; + customerSelect.dataset.autofilled = 'true'; + } + updateParentSummary(); + }); + updateParentSummary(); + } + + if (customerSelect) { + customerSelect.addEventListener('change', function() { + customerSelect.dataset.autofilled = 'false'; + }); + } + // Form submission form.addEventListener('submit', async function(e) { e.preventDefault(); @@ -202,6 +336,8 @@ document.addEventListener('DOMContentLoaded', function() { const data = { name: formData.get('name'), location_type: formData.get('location_type'), + parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null, + customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null, is_active: formData.get('is_active') === 'on', address_street: formData.get('address_street'), address_city: formData.get('address_city'), diff --git a/app/modules/locations/templates/detail.html b/app/modules/locations/templates/detail.html index ae899b1..54b713b 100644 --- a/app/modules/locations/templates/detail.html +++ b/app/modules/locations/templates/detail.html @@ -715,9 +715,15 @@
-
Tilføj underlokation
+
+
Tilføj underlokation
+ + Fuld formular + +
+

Den hurtige formular opretter direkte under {{ location.name }}. Brug fuld formular, hvis du også vil sætte adresse, noter eller GPS med det samme.

@@ -747,7 +753,7 @@ {% if customers %} {% for customer in customers %} - + {% endfor %} {% endif %} diff --git a/app/modules/locations/templates/edit.html b/app/modules/locations/templates/edit.html index d24da69..9c24685 100644 --- a/app/modules/locations/templates/edit.html +++ b/app/modules/locations/templates/edit.html @@ -2,6 +2,40 @@ {% block title %}Rediger {{ location.name }} - BMC Hub{% endblock %} +{% block extra_css %} + +{% endblock %} + {% block content %}
@@ -58,19 +92,52 @@
-
- - -
Bruges til hierarki (fx Bygning → Etage → Rum).
+
+ +
+ + +
+ +
+ +
Listen viser hele stien, så du ikke skal gætte, hvor lokationen lander.
+
+ +
+ {% if selected_parent %} +
Nuværende overordnet lokation
+
{{ selected_parent.hierarchy_path }}
+
{{ selected_parent.type_label }}
+ {% else %} +
Lokationen ligger i topniveau.
+ {% endif %} +
@@ -216,12 +283,64 @@ document.addEventListener('DOMContentLoaded', function() { const deleteModalElement = document.getElementById('deleteModal'); const deleteModal = (window.bootstrap && deleteModalElement) ? new bootstrap.Modal(deleteModalElement) : null; const locationId = '{{ location.id }}'; + const parentLocationSelect = document.getElementById('parentLocation'); + const parentLocationSearch = document.getElementById('parentLocationSearch'); + const parentSummary = document.getElementById('parentSummary'); // Character counter for notes notesField.addEventListener('input', function() { charCount.textContent = this.value.length; }); + function escapeHtml(value) { + return String(value || '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function updateParentSummary() { + const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex]; + const path = selectedOption?.dataset?.path || ''; + const type = selectedOption?.dataset?.type || ''; + + if (!selectedOption || !selectedOption.value) { + parentSummary.classList.add('empty'); + parentSummary.innerHTML = '
Lokationen ligger i topniveau.
'; + return; + } + + parentSummary.classList.remove('empty'); + parentSummary.innerHTML = ` +
Valgt overordnet lokation
+
${escapeHtml(path)}
+
${escapeHtml(type)}
+ `; + } + + function filterParentLocations() { + const query = (parentLocationSearch.value || '').trim().toLowerCase(); + Array.from(parentLocationSelect.options).forEach((option, index) => { + if (index === 0) { + option.hidden = false; + return; + } + const haystack = `${option.text} ${option.dataset.path || ''} ${option.dataset.type || ''}`.toLowerCase(); + option.hidden = query ? !haystack.includes(query) : false; + }); + } + + if (parentLocationSearch) { + parentLocationSearch.addEventListener('input', filterParentLocations); + } + + if (parentLocationSelect) { + parentLocationSelect.addEventListener('change', updateParentSummary); + updateParentSummary(); + } + // Form submission form.addEventListener('submit', async function(e) { if (form.dataset.noIntercept === 'true') { diff --git a/app/modules/locations/templates/list.html b/app/modules/locations/templates/list.html index 9a0df24..aa9e7a6 100644 --- a/app/modules/locations/templates/list.html +++ b/app/modules/locations/templates/list.html @@ -6,81 +6,271 @@ {% endblock %} @@ -96,121 +286,141 @@ -
-
-
-
-
-

Lokaliteter

-

Oversigt over alle lokationer og faciliteter

-
- Tip: Tryk / for at fokusere søgning -
-
-
-
-
Total
-
{{ total or 0 }}
-
-
-
-
-
Aktive
-
0
-
-
-
-
-
Inaktive
-
0
-
-
-
-
-
Synlige nu
-
{{ locations|length if locations else 0 }}
-
-
+
+
+
+ +

Lokaliteter

+

Få overblik over steder, underlokationer og status uden at miste hierarkiet.

-
-
- - -
-
- -
- -
- - -
-
- -
- - -
- -
- - -
- -
- - - - -
- -
-
- - -
-
-
+
Opret lokation Wizard - + Tip: Tryk / for søgning
-
- {% if total %} - Viser {{ locations|length }} af {{ total }} lokationer - {% else %} - Ingen lokationer - {% endif %} +
+
+
+
+
Total
+
{{ total or 0 }}
+
Alle registrerede lokationer
+
+
+
+
+
Aktive
+
0
+
Klar til daglig brug
+
+
+
+
+
Inaktive
+
0
+
Skjulte eller lukkede
+
+
+
+
+
Synlige nu
+
{{ locations|length if locations else 0 }}
+
Efter søgning og foldning
+
+ +
+
+
+
Filtre
+
Søg i navn og by, eller afgræns efter type og status.
+
+
+ + Hierarkiet kan foldes direkte i listen +
+
+
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+
+
+ -
+
+
+
+
+
Lokationsliste
+
+ {% if total %} + Viser {{ locations|length }} af {{ total }} lokationer + med tydeligt hierarki og status + {% else %} + Ingen lokationer endnu + {% endif %} +
+
+
+ + + Klik kun på navn eller handlinger for at åbne + + +
+
+
{% if location_tree %} @@ -250,26 +460,44 @@ 'vehicle': '#8e44ad' }.get(node.location_type, '#6c757d') %} + {% set child_count = node.children|length if node.children else 0 %} + @@ -278,20 +506,20 @@
-
- {% if node.children %} - - {% else %} - - {% endif %} - - {{ node.name }} - +
+
+ {% if node.children %} + + {% else %} + + {% endif %} +
+
+ + {{ node.name }} + +
+ {% if depth > 0 %} + Niveau {{ depth + 1 }} + {% endif %} + {% if child_count %} + {{ child_count }} underlokationer + {% endif %} + {% if node.address_city %} + {{ node.address_city }} + {% endif %} + ID {{ node.id }} +
+
- + {{ type_label }} {% if node.is_active %} - Aktiv + Aktiv {% else %} - Inaktiv + Inaktiv {% endif %} -
- +
+ - + -
+
+ + +
Vælg sagstype for at vise de relevante felter.
+
+
Relationer
@@ -215,6 +221,7 @@
+
Hardware (AnyDesk)
@@ -246,23 +253,38 @@
+ + +
+
+
Pipeline
+
+
+
+
+
+
+
+ +
+
+
+
Indkøb og salg
+
+ + +
+
+
+
Tilføj en indkøbs- eller salgslinje efter behov.
+

Type, Status & Ansvar
-
- - -
-
+
-
+
-
+
` : ''; + const container = document.getElementById('orderLines'); + container.insertAdjacentHTML('beforeend', ` +
+
${label}
+ +
+
+
+
+
+
+
+
+
+ ${purpose} +
+
`); + renderOrderLinesEmptyState(); + } + + function removeOrderLine(id) { + document.querySelector(`.order-line[data-line-id="${id}"]`)?.remove(); + renderOrderLinesEmptyState(); + } + + function collectOrderItems() { + return Array.from(document.querySelectorAll('#orderLines .order-line')).map(line => ({ + type: line.querySelector('.order-type').value, + description: line.querySelector('.order-description').value.trim(), + quantity: line.querySelector('.order-quantity').value || null, + unit: line.querySelector('.order-unit').value.trim() || null, + unit_price: line.querySelector('.order-unit-price').value || null, + amount: line.querySelector('.order-amount').value || null, + currency: line.querySelector('.order-currency').value.trim() || 'DKK', + status: line.querySelector('.order-status').value, + external_ref: line.querySelector('.order-reference').value.trim() || null, + purchase_purpose: line.querySelector('.order-purpose')?.value || null + })); + } + + async function loadPipelineStages() { + const select = document.getElementById('pipeline_stage_id'); + if (!select) return; + try { + const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' }); + if (!response.ok) return; + const stages = await response.json(); + select.innerHTML = '' + (stages || []).map(stage => ``).join(''); + } catch (err) { console.error('Failed to load pipeline stages', err); } + } + async function loadCaseTypesSelect() { const select = document.getElementById('type'); if (!select) return; try { - const res = await fetch('/api/v1/settings/case_types'); - if (!res.ok) return; - const setting = await res.json(); - const types = JSON.parse(setting.value || '[]'); - if (!Array.isArray(types) || types.length === 0) return; - - select.innerHTML = types - .map((type) => ``) - .join(''); + const [typesRes, profileRes] = await Promise.all([ + fetch('/api/v1/settings/case_types', { credentials: 'include' }), + fetch('/api/v1/auth/me/profile', { credentials: 'include' }) + ]); + const setting = typesRes.ok ? await typesRes.json() : { value: '[]' }; + const profile = profileRes.ok ? await profileRes.json() : {}; + const configured = JSON.parse(setting.value || '[]'); + const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : []; + if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline'); + const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels); + select.innerHTML = finalTypes.map(type => ``).join(''); + select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]); } catch (err) { console.error('Failed to load case types', err); + select.innerHTML = Object.entries(caseTypeLabels).map(([type, label]) => ``).join(''); } + updateCaseTypeSections(); } // --- Initialization --- document.addEventListener('DOMContentLoaded', () => { initializeSearch(); loadCaseTypesSelect(); + loadPipelineStages(); + document.getElementById('type')?.addEventListener('change', updateCaseTypeSections); applyTelefoniPrefill(); }); @@ -993,6 +1101,18 @@ deadline: document.getElementById('deadline').value || null }; + if (data.type === 'pipeline') { + data.pipeline = { + stage_id: document.getElementById('pipeline_stage_id').value || null, + amount: document.getElementById('pipeline_amount').value || null, + probability: document.getElementById('pipeline_probability').value || null, + description: document.getElementById('pipeline_description').value || null + }; + } + if (data.type === 'ordre') { + data.order_items = collectOrderItems(); + } + try { const response = await fetch('/api/v1/sag', { method: 'POST', diff --git a/app/opportunities/backend/router.py b/app/opportunities/backend/router.py index f58d5ed..7805923 100644 --- a/app/opportunities/backend/router.py +++ b/app/opportunities/backend/router.py @@ -16,7 +16,9 @@ router = APIRouter() async def list_opportunities( q: Optional[str] = None, stage: Optional[str] = None, - status: Optional[str] = None + status: Optional[str] = None, + customer_id: Optional[int] = Query(default=None), + contact_id: Optional[int] = Query(default=None), ): """ List all 'pipeline' cases. @@ -70,6 +72,33 @@ async def list_opportunities( if q: query += " AND (s.titel ILIKE %s OR c.name ILIKE %s)" params.extend([f"%{q}%", f"%{q}%"]) + + if customer_id is not None: + query += """ + AND ( + s.customer_id = %s + OR EXISTS ( + SELECT 1 + FROM sag_kunder sk + WHERE sk.sag_id = s.id + AND sk.customer_id = %s + AND sk.deleted_at IS NULL + ) + ) + """ + params.extend([customer_id, customer_id]) + + if contact_id is not None: + query += """ + AND EXISTS ( + SELECT 1 + FROM sag_kontakter sk + WHERE sk.sag_id = s.id + AND sk.contact_id = %s + AND sk.deleted_at IS NULL + ) + """ + params.append(contact_id) if status and status != 'all': if status == 'open': diff --git a/app/opportunities/frontend/views.py b/app/opportunities/frontend/views.py index ca9b4cc..9a62947 100644 --- a/app/opportunities/frontend/views.py +++ b/app/opportunities/frontend/views.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Request from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse +from fastapi.responses import RedirectResponse router = APIRouter() templates = Jinja2Templates(directory="app") @@ -9,3 +10,8 @@ templates = Jinja2Templates(directory="app") @router.get("/opportunities", response_class=HTMLResponse) async def opportunities_page(request: Request): return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"request": request}) + + +@router.get("/opportunities/{opportunity_id}", include_in_schema=False) +async def opportunity_detail_redirect(opportunity_id: int): + return RedirectResponse(url=f"/sag/{opportunity_id}/v3", status_code=307) diff --git a/app/services/subscription_matrix.py b/app/services/subscription_matrix.py index 0efebe9..c55a85e 100644 --- a/app/services/subscription_matrix.py +++ b/app/services/subscription_matrix.py @@ -14,7 +14,6 @@ import json from datetime import datetime, timedelta from typing import Dict, List, Optional, Tuple from collections import defaultdict -from app.services.economic_service import get_economic_service from app.core.database import execute_query logger = logging.getLogger(__name__) @@ -23,9 +22,6 @@ logger = logging.getLogger(__name__) class SubscriptionMatrixService: """Generate billing matrix for customer subscriptions""" - def __init__(self): - self.economic_service = get_economic_service() - async def generate_billing_matrix( self, customer_id: int, @@ -87,13 +83,9 @@ class SubscriptionMatrixService: economic_customer_number = customer[0]['economic_customer_number'] logger.info(f"📊 Generating matrix for e-conomic customer {economic_customer_number}") - # Fetch invoices from e-conomic - logger.info(f"🔍 [MATRIX] About to call get_customer_invoices with customer {economic_customer_number}") - invoices = await self.economic_service.get_customer_invoices( - economic_customer_number, - include_lines=True - ) - logger.info(f"🔍 [MATRIX] Returned {len(invoices)} invoices from e-conomic") + # Fetch imported invoice snapshot from local invoice_error_finder tables + invoices = self._load_imported_invoices(str(economic_customer_number)) + logger.info(f"🔍 [MATRIX] Loaded %s imported invoices from local snapshot", len(invoices)) if not invoices: logger.warning(f"⚠️ No invoices found for customer {economic_customer_number}") @@ -130,6 +122,125 @@ class SubscriptionMatrixService: "error": str(e), "products": [] } + + def _load_imported_invoices(self, economic_customer_number: str) -> List[Dict]: + rows = execute_query( + """ + WITH ranked_invoices AS ( + SELECT + inv.id, + inv.source_invoice_number, + inv.source_type, + inv.invoice_date, + inv.due_date, + inv.net_amount, + inv.vat_amount, + inv.total_amount, + inv.currency, + inv.source_raw, + CASE inv.source_type + WHEN 'paid' THEN 1 + WHEN 'booked' THEN 2 + WHEN 'unpaid' THEN 3 + WHEN 'draft' THEN 4 + ELSE 9 + END AS source_rank + FROM invoice_error_finder_economic_invoices inv + WHERE inv.customer_number = %s + ), + selected_invoices AS ( + SELECT DISTINCT ON (source_invoice_number) + id, + source_invoice_number, + source_type, + invoice_date, + due_date, + net_amount, + vat_amount, + total_amount, + currency, + source_raw + FROM ranked_invoices + ORDER BY source_invoice_number, source_rank, invoice_date DESC, id DESC + ) + SELECT + si.id AS invoice_id, + si.source_invoice_number, + si.source_type, + si.invoice_date, + si.due_date, + si.net_amount, + si.vat_amount, + si.total_amount, + si.currency, + si.source_raw AS invoice_source_raw, + line.line_number, + line.product_number, + line.product_name, + line.description, + line.quantity, + line.unit_price, + line.line_net_amount, + line.source_raw AS line_source_raw + FROM selected_invoices si + LEFT JOIN invoice_error_finder_economic_invoice_lines line + ON line.invoice_id = si.id + ORDER BY si.invoice_date DESC NULLS LAST, si.source_invoice_number DESC, line.line_number ASC + """, + (economic_customer_number,), + ) or [] + + invoices: List[Dict] = [] + invoices_by_id: Dict[int, Dict] = {} + + for row in rows: + invoice_id = row.get("invoice_id") + if invoice_id is None: + continue + + invoice_source_raw = self._ensure_dict(row.get("invoice_source_raw")) + line_source_raw = self._ensure_dict(row.get("line_source_raw")) + + if invoice_id not in invoices_by_id: + invoice_payload = { + "id": invoice_id, + "status": row.get("source_type"), + "bookedInvoiceNumber": row.get("source_invoice_number"), + "date": row.get("invoice_date").isoformat() if row.get("invoice_date") else None, + "dueDate": row.get("due_date").isoformat() if row.get("due_date") else None, + "netAmount": float(row.get("net_amount") or 0), + "vatAmount": float(row.get("vat_amount") or 0), + "grossAmount": float(row.get("total_amount") or 0), + "currency": row.get("currency") or "DKK", + "notes": invoice_source_raw.get("notes"), + "heading": invoice_source_raw.get("heading"), + "description": invoice_source_raw.get("description"), + "text": invoice_source_raw.get("text"), + "subject": invoice_source_raw.get("subject"), + "otherReference": invoice_source_raw.get("otherReference"), + "orderNumberDb": invoice_source_raw.get("orderNumberDb"), + "lines": [], + } + invoices_by_id[invoice_id] = invoice_payload + invoices.append(invoice_payload) + + if row.get("line_number") is None: + continue + + invoices_by_id[invoice_id]["lines"].append({ + "lineNumber": row.get("line_number"), + "description": row.get("description"), + "quantity": float(row.get("quantity") or 0), + "unitNetPrice": float(row.get("unit_price") or 0), + "totalNetAmount": float(row.get("line_net_amount") or 0), + "period": (line_source_raw.get("period") if isinstance(line_source_raw, dict) else None) or {}, + "product": { + "productNumber": row.get("product_number"), + "name": row.get("product_name"), + }, + }) + + return invoices def _aggregate_by_product(self, invoices: List[Dict], months: int) -> List[Dict]: """ @@ -377,6 +488,18 @@ class SubscriptionMatrixService: }) return products + + @staticmethod + def _ensure_dict(value) -> Dict: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except json.JSONDecodeError: + return {} + return {} @staticmethod def _generate_month_range(num_months: int) -> List[str]: diff --git a/app/settings/backend/router.py b/app/settings/backend/router.py index 9b348f5..7b91958 100644 --- a/app/settings/backend/router.py +++ b/app/settings/backend/router.py @@ -168,7 +168,7 @@ async def get_setting(key: str): seed_query, ( "case_types", - '["ticket", "opgave", "ordre", "projekt", "service"]', + '["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]', "system", "Sags-typer", "json", @@ -992,4 +992,3 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}") err = str(e) or e.__class__.__name__ raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}") - diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html index c9d4fa3..bd9b868 100644 --- a/app/settings/frontend/settings.html +++ b/app/settings/frontend/settings.html @@ -184,6 +184,16 @@
+
+
+
+
Faktura-fejl-finder
+

Varetekster som skal ignoreres i analysen, fx gebyrer, porto og fragt.

+
+
+
+
+
@@ -2374,7 +2384,8 @@ async function testTelefoniCall() { function renderDriftConnectors() { const container = document.getElementById('driftConnectorCards'); - if (!container) return; + const invoiceErrorFinderContainer = document.getElementById('invoiceErrorFinderSettingsCard'); + if (!container && !invoiceErrorFinderContainer) return; const connectors = [ { @@ -2440,10 +2451,35 @@ function renderDriftConnectors() {
` + }, + { + key: 'invoice-error-finder', + title: 'Faktura-fejl-finder', + description: 'Styr hvilke varetekster der skal ignoreres, sa gebyrer og fragt ikke opretter falske fejl.', + badge: 'Okonomi', + body: ` +
+
+ +
+ + +
+
Matcher paa varetekst og beskrivelse i e-conomic samt varenavn i Simply-ordrer.
+
+
+
+
+ + +
+ ` } ]; - container.innerHTML = connectors.map(connector => ` + const renderConnectorCard = (connector) => `
@@ -2454,7 +2490,21 @@ function renderDriftConnectors() {
${connector.body}
- `).join(''); + `; + + if (container) { + container.innerHTML = connectors + .filter(connector => connector.key !== 'invoice-error-finder') + .map(renderConnectorCard) + .join(''); + } + + if (invoiceErrorFinderContainer) { + const invoiceErrorFinderConnector = connectors.find(connector => connector.key === 'invoice-error-finder'); + invoiceErrorFinderContainer.innerHTML = invoiceErrorFinderConnector + ? renderConnectorCard(invoiceErrorFinderConnector) + : ''; + } } async function loadSettings() { @@ -2477,6 +2527,7 @@ async function loadSettings() { renderDriftConnectors(); await loadUptimeKumaSettings(); await loadUISPSettings(); + await loadInvoiceErrorFinderSettings(); await loadLabelPrinterSettings(); } catch (error) { console.error('Error loading settings:', error); @@ -2805,6 +2856,30 @@ async function loadUISPSettings() { } let driftBlacklistItems = []; +const DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS = [ + 'faktureringsgebyr', + 'gebyr', + 'porto', + 'fragt', + 'fragtomkostning', + 'forsendelse', + 'shipping', + 'levering', + 'engangsydelse', + 'engangsarbejde', + 'oprettelse', + 'opstartsgebyr', + 'installation', + 'installationsgebyr', + 'timeforbrug', + 'arbejdstid', + 'konsulenttimer', + 'supporttid', + 'teknikertid', + 'montørtimer', + 'projektarbejde' +]; +let invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS]; function parseDriftBlacklistValue(rawValue) { const raw = String(rawValue || '').trim(); @@ -2862,6 +2937,131 @@ function removeDriftBlacklistItem(item) { renderDriftBlacklistList(); } +function parseInvoiceErrorFinderIgnoreValue(rawValue) { + const raw = String(rawValue || '').trim(); + if (!raw) return [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS]; + + let values = []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + values = parsed; + } else if (typeof parsed === 'string') { + values = [parsed]; + } + } catch (e) { + values = raw.replaceAll(';', '\n').replaceAll(',', '\n').split('\n'); + } + + const seen = new Set(); + const cleaned = []; + [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS, ...values].forEach(item => { + const normalized = String(item || '').trim().toLowerCase(); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + cleaned.push(normalized); + }); + return cleaned; +} + +function renderInvoiceErrorFinderIgnoreList() { + const list = document.getElementById('invoiceErrorFinderIgnoreList'); + if (!list) return; + if (!invoiceErrorFinderIgnoreItems.length) { + list.innerHTML = 'Ingen varetekster ignoreres endnu.'; + return; + } + list.innerHTML = invoiceErrorFinderIgnoreItems.map(item => { + const safe = String(item).replace(//g, '>'); + return `${safe} `; + }).join(''); +} + +function addInvoiceErrorFinderIgnoreItem() { + const input = document.getElementById('invoiceErrorFinderIgnoreInput'); + if (!input) return; + const value = String(input.value || '').trim().toLowerCase(); + if (!value) return; + if (!invoiceErrorFinderIgnoreItems.includes(value)) { + invoiceErrorFinderIgnoreItems.push(value); + } + input.value = ''; + renderInvoiceErrorFinderIgnoreList(); +} + +function removeInvoiceErrorFinderIgnoreItem(item) { + invoiceErrorFinderIgnoreItems = invoiceErrorFinderIgnoreItems.filter(v => v !== item); + renderInvoiceErrorFinderIgnoreList(); +} + +async function loadInvoiceErrorFinderSettings() { + try { + const response = await fetch('/api/v1/settings/invoice_error_finder_ignored_product_texts', { credentials: 'include' }); + if (!response.ok) { + invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS]; + renderInvoiceErrorFinderIgnoreList(); + return; + } + + const setting = await response.json(); + invoiceErrorFinderIgnoreItems = parseInvoiceErrorFinderIgnoreValue(setting?.value || '[]'); + renderInvoiceErrorFinderIgnoreList(); + } catch (e) { + console.warn('Invoice Error Finder settings load failed:', e); + invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS]; + renderInvoiceErrorFinderIgnoreList(); + } +} + +async function saveInvoiceErrorFinderSettings() { + const statusEl = document.getElementById('invoiceErrorFinderSaveStatus'); + statusEl.textContent = 'Gemmer...'; + statusEl.className = 'small text-muted'; + + const value = JSON.stringify(parseInvoiceErrorFinderIgnoreValue(invoiceErrorFinderIgnoreItems)); + const payload = { + key: 'invoice_error_finder_ignored_product_texts', + value, + category: 'finance', + description: 'JSON array of invoice product texts/descriptions ignored by Invoice Error Finder', + value_type: 'string', + is_public: false + }; + + try { + let response = await fetch('/api/v1/settings/invoice_error_finder_ignored_product_texts', { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value }) + }); + + if (response.status === 404 || response.status === 405) { + response = await fetch('/api/v1/settings', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + } + + if (!response.ok) { + throw new Error(await getErrorMessage(response, 'Kunne ikke gemme ignore-listen')); + } + + invoiceErrorFinderIgnoreItems = parseInvoiceErrorFinderIgnoreValue(value); + renderInvoiceErrorFinderIgnoreList(); + statusEl.textContent = '✅ Gemt'; + statusEl.className = 'small text-success'; + setTimeout(() => { statusEl.textContent = ''; }, 3000); + showNotification('Ignore-liste gemt', 'success'); + } catch (error) { + statusEl.textContent = '❌ Kunne ikke gemme'; + statusEl.className = 'small text-danger'; + showNotification(error.message || 'Kunne ikke gemme ignore-listen', 'error'); + } +} + async function saveUISPSettings() { const baseUrl = (document.getElementById('uispBaseUrl').value || '').trim(); const apiToken = (document.getElementById('uispApiToken').value || '').trim(); diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html index 2f8b39e..bf51814 100644 --- a/app/shared/frontend/base.html +++ b/app/shared/frontend/base.html @@ -1704,13 +1704,13 @@ if (bmcOriginalFetch) { if (e.key === '+' && !e.ctrlKey && !e.metaKey && !e.shiftKey) { if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return; e.preventDefault(); - openQuickCreateModal(); + openNewCasePage(); } // Cmd+Shift+C / Ctrl+Shift+C for QuickCreate if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') { e.preventDefault(); - openQuickCreateModal(); + openNewCasePage(); } // ESC to close @@ -1719,22 +1719,14 @@ if (bmcOriginalFetch) { } }); - // QuickCreate modal opener function - function openQuickCreateModal() { - const quickCreateModal = new bootstrap.Modal(document.getElementById('quickCreateModal')); - quickCreateModal.show(); - setTimeout(() => { - const textInput = document.getElementById('quickCreateText'); - if (textInput) { - textInput.focus(); - } - }, 300); + function openNewCasePage() { + window.location.href = '/sag/new'; } // QuickCreate button click handler document.getElementById('quickCreateBtn')?.addEventListener('click', (e) => { e.preventDefault(); - openQuickCreateModal(); + openNewCasePage(); }); // Reset search when modal is closed @@ -2257,9 +2249,6 @@ if (bmcOriginalFetch) { }); - -{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %} - {% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %} @@ -2305,6 +2294,11 @@ if (bmcOriginalFetch) {
+
+ + +
Bruges som udgangspunkt på “Ny sag”.
+