diff --git a/app/admin/hub_impact.html b/app/admin/hub_impact.html index eebb4c5..8901c47 100644 --- a/app/admin/hub_impact.html +++ b/app/admin/hub_impact.html @@ -5,8 +5,8 @@ .ct-shell{max-width:1500px;margin:auto}.ct-hero{border-radius:24px;padding:2rem;background:linear-gradient(120deg,#082f49,#0f4c75 55%,#2563eb);color:#fff;box-shadow:0 22px 55px rgba(8,47,73,.22)} .ct-panel{background:var(--bs-body-bg);border:1px solid rgba(15,76,117,.14);border-radius:18px;box-shadow:0 10px 28px rgba(15,76,117,.06)} .ct-kpi{border-radius:16px;padding:1.1rem;background:linear-gradient(145deg,rgba(15,76,117,.08),rgba(37,99,235,.03));height:100%}.ct-kpi strong{font-size:1.7rem}.ct-delta.positive{color:#16803c}.ct-delta.negative{color:#dc3545} -.ct-status{display:inline-flex;align-items:center;gap:.45rem;padding:.38rem .75rem;border-radius:999px;font-weight:700}.ct-status.ready{background:#dcfce7;color:#166534}.ct-status.blocked{background:#fee2e2;color:#991b1b} -.ct-table th{font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:#64748b}.ct-empty{padding:3rem;text-align:center;color:#64748b} +.ct-status{display:inline-flex;align-items:center;gap:.45rem;padding:.38rem .75rem;border-radius:999px;font-weight:700}.ct-status.ready{background:var(--theme-success-bg, #dcfce7);color:var(--theme-success-text, #166534)}.ct-status.blocked{background:var(--theme-danger-bg, #fee2e2);color:var(--theme-danger-text, #991b1b)} +.ct-table th{font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:var(--theme-muted, #64748b)}.ct-empty{padding:3rem;text-align:center;color:var(--theme-muted, #64748b)} {% endblock %} {% block content %} diff --git a/app/anydesk/frontend/sessions.html b/app/anydesk/frontend/sessions.html index ba6a39f..bc4c9ce 100644 --- a/app/anydesk/frontend/sessions.html +++ b/app/anydesk/frontend/sessions.html @@ -91,7 +91,7 @@ /* badges */ .badge-link { display:inline-flex; align-items:center; gap:.3rem; font-size:.78rem; padding:.2rem .55rem; border-radius:5px; font-weight:500; white-space:nowrap; } -.badge-hw { background:rgba(15,76,117,.12); color:#0f4c75; } +.badge-hw { background:rgba(15,76,117,.12); color:var(--theme-primary-text, #0f4c75); } .badge-cust{ background:rgba(40,167,69,.12); color:#1a6e30; } .badge-cont{ background:rgba(255,140,0,.13); color:#b36200; } .badge-sag { background:rgba(111,66,193,.13); color:#4b1d8c; } diff --git a/app/billing/frontend/supplier_invoices.html b/app/billing/frontend/supplier_invoices.html index d0715b5..bd9eafc 100644 --- a/app/billing/frontend/supplier_invoices.html +++ b/app/billing/frontend/supplier_invoices.html @@ -142,7 +142,7 @@ border: 1px solid rgba(0,0,0,0.08); border-radius: 10px; padding: 0.7rem 0.85rem; - background: #fff; + background: var(--theme-surface, #fff); } {% endblock %} @@ -5426,7 +5426,7 @@ function showLoadingOverlay(message = 'Behandler...') { z-index: 9999; `; overlay.innerHTML = ` -
+
Loading...
diff --git a/app/billing/frontend/sync_dashboard.html b/app/billing/frontend/sync_dashboard.html index cb05cea..9c18e57 100644 --- a/app/billing/frontend/sync_dashboard.html +++ b/app/billing/frontend/sync_dashboard.html @@ -58,7 +58,7 @@ .status-pending { background: rgba(192, 86, 33, 0.14); color: var(--sync-warn); } .status-exported { background: rgba(15, 76, 117, 0.14); color: var(--sync-accent); } .status-failed { background: rgba(197, 48, 48, 0.14); color: var(--sync-danger); } - .status-posted { background: rgba(22, 101, 52, 0.14); color: #166534; } + .status-posted { background: rgba(22, 101, 52, 0.14); color: var(--theme-success-text, #166534); } .status-paid { background: rgba(47, 133, 90, 0.14); color: var(--sync-ok); } .table thead th { diff --git a/app/contacts/backend/router_simple.py b/app/contacts/backend/router_simple.py index 69912dc..9f110de 100644 --- a/app/contacts/backend/router_simple.py +++ b/app/contacts/backend/router_simple.py @@ -243,12 +243,12 @@ def _company_from_email_body(body: str, contact_name: str = "") -> dict: @router.post("/contacts/analyze-email") -async def analyze_email_for_new_contact(file: UploadFile = File(...)): +async def analyze_email_for_new_contact(file: UploadFile = File(...), use_ai: bool = False): """Return editable contact/company candidates without creating anything.""" filename = str(file.filename or "").lower() if not filename.endswith((".eml", ".msg")): raise HTTPException(status_code=400, detail="Kun Outlook .msg- og .eml-filer understøttes") - content = await file.read() + content = await file.read(10 * 1024 * 1024 + 1) if not content or len(content) > 10 * 1024 * 1024: raise HTTPException(status_code=400, detail="Mailfilen er tom eller større end 10 MB") from app.services.email_service import EmailService @@ -260,6 +260,13 @@ async def analyze_email_for_new_contact(file: UploadFile = File(...)): empty_contact = {field: None for field in ("first_name", "last_name", "email", "phone", "mobile", "title", "department")} suggestions = _contact_suggestions_from_email(empty_contact, parsed) contact = {item["field"]: item["suggested"] for item in suggestions} + if use_ai: + from app.services.contact_mail_ai import extract_contact + try: + ai_contact = await extract_contact(parsed) + contact.update(ai_contact) + except Exception: + raise HTTPException(status_code=502, detail="AI kunne ikke analysere mailen. Kontrollér AI-indstillingerne og prøv igen.") contact_name = " ".join(filter(None, (contact.get("first_name"), contact.get("last_name")))) company = _company_from_email_body(_plain_email_body(parsed), contact_name) company["existing_id"] = None @@ -831,6 +838,16 @@ async def merge_contact(contact_id: int, request: ContactMergeRequest): release_db_connection(conn) +@router.get("/contacts/{contact_id}/vendors") +async def get_contact_vendors(contact_id: int): + """Supplier relations, kept separate from customer/company semantics.""" + if not execute_query_single("SELECT id FROM contacts WHERE id=%s", (contact_id,)): + raise HTTPException(status_code=404, detail="Contact not found") + return execute_query("""SELECT v.id, v.name, vc.role, vc.is_primary + FROM vendor_contacts vc JOIN vendors v ON v.id=vc.vendor_id + WHERE vc.contact_id=%s ORDER BY vc.is_primary DESC, v.name, v.id""", (contact_id,)) or [] + + @router.get("/contacts/{contact_id}") async def get_contact(contact_id: int): """Get a single contact by ID with linked companies""" diff --git a/app/contacts/frontend/contact_detail.html b/app/contacts/frontend/contact_detail.html index dafbce9..cc2ab5c 100644 --- a/app/contacts/frontend/contact_detail.html +++ b/app/contacts/frontend/contact_detail.html @@ -165,6 +165,7 @@
+
@@ -348,6 +349,10 @@
+
+
Tilknyttede leverandører
+
+
@@ -1207,6 +1212,7 @@ function displayContact(contact) { ? 'Aktiv' : 'Inaktiv'; document.getElementById('companyCount').textContent = contact.companies ? contact.companies.length : 0; + loadContactVendors(); populateSessionCompanySelect(contact); @@ -1349,6 +1355,21 @@ async function loadCompanies() { displayCompanies(contactData.companies); } +async function loadContactVendors() { + const summary=document.getElementById('contactVendorSummary'); + try { + const response=await fetch(`/api/v1/contacts/${contactId}/vendors`); + if(!response.ok)throw new Error('Leverandørkoblinger kunne ikke hentes'); + const vendors=await response.json(); + summary.classList.toggle('d-none',vendors.length===0); + summary.innerHTML=vendors.length ? `Leverandørkontakt
${vendors.map(v=>`Leverandør: ${escapeHtml(v.name)}`).join('')}
` : ''; + document.getElementById('contactVendorsSection').classList.toggle('d-none',vendors.length===0); + document.getElementById('contactVendorsContainer').innerHTML=vendors.map(v=>`
Leverandør
${escapeHtml(v.name)}
${v.is_primary?'Primær kontakt':''}${v.role?`
Rolle: ${escapeHtml(v.role)}
`:''}
`).join(''); + } catch(error) { + summary.classList.remove('d-none');summary.textContent=error.message; + } +} + function displayCompanies(companies) { const container = document.getElementById('companiesContainer'); diff --git a/app/contacts/frontend/contacts.html b/app/contacts/frontend/contacts.html index 2f65881..c872d92 100644 --- a/app/contacts/frontend/contacts.html +++ b/app/contacts/frontend/contacts.html @@ -893,6 +893,7 @@ let selectedCompanyIds = new Set(); let currentContactsData = []; let pendingCreateModalCustomerId = null; let pendingCreateReturnTo = null; +let pendingCreateVendorId = null; let analyzedMailCompany = null; let currentSort = { key: 'name', @@ -917,6 +918,8 @@ document.addEventListener('DOMContentLoaded', () => { const preselectedCustomerId = Number(urlParams.get('customer_id')); const shouldOpenCreateModal = urlParams.get('create') === '1'; pendingCreateReturnTo = urlParams.get('return_to') || null; + const vendorId = Number(urlParams.get('vendor_id')); + pendingCreateVendorId = Number.isInteger(vendorId) && vendorId > 0 ? vendorId : null; pendingCreateModalCustomerId = Number.isFinite(preselectedCustomerId) && preselectedCustomerId > 0 ? preselectedCustomerId : null; @@ -1710,6 +1713,17 @@ async function createContact() { const createdContactId = Number(newContact?.id) || null; + if (pendingCreateVendorId && createdContactId) { + const linkResponse = await fetch(`/api/v1/vendors/${pendingCreateVendorId}/contacts`, { + method:'POST', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({contact_id:createdContactId,role:contactData.role || '',is_primary:contactData.is_primary}) + }).catch(() => null); + if (!linkResponse || !linkResponse.ok) { + alert('Kontaktpersonen er oprettet, men kunne ikke tilknyttes leverandøren. Tilknyt den fra leverandørkortet.'); + window.location.href = `/contacts/${createdContactId}`; + return; + } + } if (pendingCreateReturnTo) { window.location.href = pendingCreateReturnTo; return; diff --git a/app/conversations/frontend/templates/my_conversations.html b/app/conversations/frontend/templates/my_conversations.html index f89da1f..22fa29c 100644 --- a/app/conversations/frontend/templates/my_conversations.html +++ b/app/conversations/frontend/templates/my_conversations.html @@ -59,12 +59,12 @@ + {% include "alert_notes/frontend/alert_box.html" %} @@ -2812,6 +2687,8 @@ function displayCustomer(customer) { document.getElementById('barred').innerHTML = customer.barred ? 'Ja' : 'Nej'; + document.getElementById('invoiceFeeExempt').checked = customer.invoice_fee_exempt === true; + document.getElementById('invoiceFeeAmount').value = customer.invoice_fee_amount ?? ''; document.getElementById('supplierServiceEnrolled').checked = customer.supplier_service_enrolled === true; // Integration @@ -2828,6 +2705,28 @@ function displayCustomer(customer) { loadAndDisplayAlerts('customer', customer.id, 'inline', 'alert-notes-container'); } +async function saveCustomerInvoiceFee() { + const amount = document.getElementById('invoiceFeeAmount'); + if (!amount.reportValidity()) return; + const button = document.getElementById('invoiceFeeSave'); + const status = document.getElementById('invoiceFeeStatus'); + button.disabled = true; + status.textContent = 'Gemmer…'; + try { + const payload = {invoice_fee_exempt: document.getElementById('invoiceFeeExempt').checked, + invoice_fee_amount: amount.value === '' ? null : Number(amount.value)}; + const response = await fetch(`/api/v1/customers/${customerId}`, { + method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload) + }); + if (!response.ok) throw new Error((await response.json()).detail || 'Kunne ikke gemme gebyrindstillinger'); + Object.assign(customerData, payload); + status.className = 'small text-success mt-1'; + status.textContent = 'Gemt. Gælder nye ordrer.'; + } catch (error) { + status.className = 'small text-danger mt-1'; status.textContent = error.message; + } finally { button.disabled = false; } +} + async function saveSupplierServiceEnrollment(checkbox) { const previous = !checkbox.checked; const status = document.getElementById('supplierServiceStatus'); @@ -3760,57 +3659,13 @@ function renderContactActionButtons(number, displayName, contactId) { } function buildContactsRows(contacts) { - return contacts.map(contact => { - const displayName = getContactDisplayName(contact); - const contactId = Number(contact.id) || null; - const mobileValue = getContactMobileValue(contact); - const phoneValue = getContactPhoneValue(contact); - const titleValue = String(contact.title || contact.role || '').trim(); - const nameCell = contactId - ? `${escapeHtml(displayName)}` - : escapeHtml(displayName); - - const email = contact.email ? `${escapeHtml(contact.email)}` : '—'; - const phone = phoneValue - ? `
${escapeHtml(phoneValue)}${renderContactActionButtons(phoneValue, displayName, contactId)}
` - : '—'; - const mobile = mobileValue - ? `
${escapeHtml(mobileValue)}${renderContactActionButtons(mobileValue, displayName, contactId)}
` - : '—'; - const title = titleValue ? escapeHtml(titleValue) : '—'; - const primaryBadge = contact.is_primary ? 'Primær' : '—'; - - return ` - - ${nameCell} - ${title} - ${email} - ${phone} - ${mobile} - ${primaryBadge} - - `; - }).join(''); + return CompanyContacts.rows(contacts, {name:getContactDisplayName, mobile:getContactMobileValue, + phone:getContactPhoneValue, actions:renderContactActionButtons}); } function getFilteredContacts() { - const query = contactsSearchQuery.toLowerCase(); - return customerContactsData.filter(contact => { - const displayName = getContactDisplayName(contact); - const phoneValue = getContactPhoneValue(contact); - const mobileValue = getContactMobileValue(contact); - const searchable = [ - displayName, - String(contact.title || contact.role || ''), - String(contact.email || ''), - phoneValue, - mobileValue - ].join(' ').toLowerCase(); - - const matchesQuery = !query || searchable.includes(query); - const matchesCallable = !contactsOnlyCallable || Boolean(phoneValue || mobileValue); - return matchesQuery && matchesCallable; - }); + return CompanyContacts.filter(customerContactsData, contactsSearchQuery, contactsOnlyCallable, + {name:getContactDisplayName,phone:getContactPhoneValue,mobile:getContactMobileValue}); } function updateContactsResultCount(current, total) { @@ -3832,23 +3687,7 @@ function renderContactsFromState() { const container = document.getElementById('contactsContainer'); if (!container) return; - const renderContactsTable = (bodyHtml) => ` - - - - - - - - - - - - - ${bodyHtml} - -
NavnTitelEmailTelefonMobilPrimær
- `; + const renderContactsTable = CompanyContacts.table; if (!customerContactsData.length) { container.innerHTML = '
Ingen kontakter endnu
'; diff --git a/app/customers/frontend/pipeline.html b/app/customers/frontend/pipeline.html index 01fc629..6879282 100644 --- a/app/customers/frontend/pipeline.html +++ b/app/customers/frontend/pipeline.html @@ -22,7 +22,7 @@ border: 1px solid rgba(0,0,0,0.08); border-radius: 10px; padding: 0.75rem; - background: white; + background: var(--theme-surface, white); margin-bottom: 0.75rem; } diff --git a/app/dashboard/frontend/index.html b/app/dashboard/frontend/index.html index 3acb2f6..dff1d08 100644 --- a/app/dashboard/frontend/index.html +++ b/app/dashboard/frontend/index.html @@ -37,7 +37,7 @@ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); text-decoration: none; display: block; - background: white; + background: var(--theme-surface, white); border: none; box-shadow: 0 2px 8px rgba(0,0,0,0.04); overflow: hidden; @@ -100,7 +100,7 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; - color: #64748b; + color: var(--theme-muted, #64748b); margin-bottom: 0.75rem; } @@ -124,22 +124,22 @@ .trend-badge.positive { background: linear-gradient(135deg, #d4fc79 0%, #96e6a1 100%); - color: #166534; + color: var(--theme-success-text, #166534); } .trend-badge.negative { background: linear-gradient(135deg, #ffeaa7 0%, #fab1a0 100%); - color: #991b1b; + color: var(--theme-danger-text, #991b1b); } .trend-badge.neutral { - background: #e2e8f0; - color: #64748b; + background: var(--theme-surface-soft, #e2e8f0); + color: var(--theme-muted, #64748b); } /* Content Cards */ .content-card { - background: white; + background: var(--theme-surface, white); border-radius: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); overflow: hidden; @@ -281,7 +281,7 @@ } .activity-feed::-webkit-scrollbar-track { - background: #f1f5f9; + background: var(--theme-surface-soft, #f1f5f9); } .activity-feed::-webkit-scrollbar-thumb { diff --git a/app/devportal/frontend/portal.html b/app/devportal/frontend/portal.html index 3a930ab..572dea0 100644 --- a/app/devportal/frontend/portal.html +++ b/app/devportal/frontend/portal.html @@ -54,7 +54,7 @@ height: 200px; object-fit: cover; border-radius: 8px; - background: #f8f9fa; + background: var(--theme-surface-soft, #f8f9fa); } {% endblock %} diff --git a/app/economy/backend/router.py b/app/economy/backend/router.py index 4dd89a9..31b1c2f 100644 --- a/app/economy/backend/router.py +++ b/app/economy/backend/router.py @@ -1,3 +1,4 @@ +from app.services.invoice_fee_service import with_invoice_fee, invoice_fee_line import logging from collections import defaultdict from datetime import date @@ -494,7 +495,10 @@ def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], us ) total_hours += qty + fee = invoice_fee_line(customer.get("hub_customer_id")) subtotal = (total_hours * hourly_rate).quantize(Decimal("0.01")) + if fee: + subtotal += Decimal(str(fee["amount"])) vat_rate = Decimal("25.00") vat_amount = (subtotal * vat_rate / Decimal("100")).quantize(Decimal("0.01")) total_amount = subtotal + vat_amount @@ -545,6 +549,14 @@ def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], us ), ) + if fee: + execute_insert("""INSERT INTO tmodule_order_lines + (order_id, line_number, description, quantity, unit_price, line_total, + time_entry_ids, product_number, product_id, source_type) + VALUES (%s, %s, %s, 1, %s, %s, %s, %s, %s, 'invoice_fee') RETURNING id""", + (order_id, len(line_payloads) + 1, fee['description'], fee['unit_price'], + fee['amount'], [], fee['product_number'], fee['product_id'])) + return int(order_id) @@ -557,7 +569,7 @@ def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str, hub_customer = execute_query_single( """ SELECT id, name, standard_hourly_rate, standard_margin_percent, - special_freight_price, supplier_service_enrolled, invoice_fee_amount + special_freight_price, supplier_service_enrolled, invoice_fee_amount, invoice_fee_exempt FROM customers WHERE id = %s """, @@ -569,13 +581,6 @@ def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str, customer_name = hub_customer.get("name") or f"Kunde {hub_customer_id}" hourly_rate = Decimal(str(hub_customer.get("standard_hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE)) - invoice_fee_amount = Decimal( - str( - (hub_customer or {}).get("invoice_fee_amount") - if (hub_customer or {}).get("invoice_fee_amount") is not None - else settings.CUSTOMER_DEFAULT_INVOICE_FEE - ) - ) special_freight_price = (hub_customer or {}).get("special_freight_price") special_freight_amount = Decimal(str(special_freight_price)) if special_freight_price is not None else Decimal("0") supplier_service_enrolled = bool((hub_customer or {}).get("supplier_service_enrolled")) @@ -673,32 +678,7 @@ def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str, } ) - # Fee line is included by default unless customer-specific value is 0. - if invoice_fee_amount > 0 and not supplier_service_enrolled: - line_payloads.append( - { - "line_key": f"invoice_fee:{hub_customer_id}", - "source_type": "invoice_fee", - "source_id": None, - "description": "Faktureringsgebyr", - "quantity": 1.0, - "unit_price": float(invoice_fee_amount.quantize(Decimal("0.01"))), - "discount_percentage": 0, - "unit": "stk", - "product_id": None, - "selected": True, - "amount": float(invoice_fee_amount.quantize(Decimal("0.01"))), - "customer_id": int(hub_customer_id), - "customer_name": customer_name, - "sag_id": None, - "time_entry_ids": [], - "time_date": None, - "meta": { - "standard_margin_percent": float(standard_margin_percent), - "supplier_service_enrolled": supplier_service_enrolled, - }, - } - ) + line_payloads = with_invoice_fee(line_payloads, hub_customer_id, legacy=True, customer=hub_customer) if not line_payloads: raise HTTPException(status_code=400, detail="No order lines generated from selected entries") diff --git a/app/economy/frontend/time_queue.html b/app/economy/frontend/time_queue.html index d1156a0..0220ab6 100644 --- a/app/economy/frontend/time_queue.html +++ b/app/economy/frontend/time_queue.html @@ -18,7 +18,7 @@ @media (max-width:1100px){.time-row .row{grid-template-columns:22px minmax(240px,2fr) minmax(115px,1fr) minmax(92px,.6fr) 78px}.time-row .col-8.col-lg-2{display:none}} @media (max-width:720px){.time-row .row{display:flex;flex-wrap:wrap;gap:.35rem}.time-row .col-12.col-lg-4{flex:1 1 calc(100% - 30px)}.time-row .col-6,.time-row .col-8{flex:1 1 auto}.time-row .col-4{margin-left:auto}} .queue-actions { position:sticky;bottom:1rem;z-index:20;box-shadow:0 .5rem 1.5rem rgba(27,56,84,.16) }.preview-group{border-left:4px solid #0d6efd}.empty-state{border:2px dashed #d7e0e8;border-radius:.9rem} -.case-detail-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}.case-detail-meta div{padding:.65rem .75rem;background:#f6f8fa;border-radius:.55rem}.case-detail-meta small{display:block;color:#6c757d;margin-bottom:.12rem}@media(max-width:576px){.case-detail-meta{grid-template-columns:1fr}} +.case-detail-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}.case-detail-meta div{padding:.65rem .75rem;background:#f6f8fa;border-radius:.55rem}.case-detail-meta small{display:block;color:var(--theme-muted, #6c757d);margin-bottom:.12rem}@media(max-width:576px){.case-detail-meta{grid-template-columns:1fr}} .case-closed-badge{background:#e8f5ec;color:#176b36;border:1px solid #bee3c8}.case-open-badge{background:#fff5dc;color:#805b00;border:1px solid #f1dda0}.preview-items{max-height:320px;overflow:auto}.preview-item{border-bottom:1px solid #e7edf1;padding:.55rem 0}.preview-item:last-child{border-bottom:0}
diff --git a/app/emails/frontend/emails.html b/app/emails/frontend/emails.html index 159668b..032718b 100644 --- a/app/emails/frontend/emails.html +++ b/app/emails/frontend/emails.html @@ -190,14 +190,14 @@ font-weight: 500; } - .classification-invoice { background: #d4edda; color: #155724; } - .classification-order_confirmation { background: #d1ecf1; color: #0c5460; } - .classification-freight_note { background: #fff3cd; color: #856404; } - .classification-time_confirmation { background: #e2e3e5; color: #383d41; } - .classification-case_notification { background: #cce5ff; color: #004085; } - .classification-bankruptcy { background: #f8d7da; color: #721c24; } + .classification-invoice { background: var(--theme-success-bg, #d4edda); color: var(--theme-success-text, #155724); } + .classification-order_confirmation { background: var(--theme-info-bg, #d1ecf1); color: var(--theme-info-text, #0c5460); } + .classification-freight_note { background: var(--theme-warning-bg, #fff3cd); color: var(--theme-warning-text, #856404); } + .classification-time_confirmation { background: var(--theme-surface-soft, #e2e3e5); color: var(--theme-text, #383d41); } + .classification-case_notification { background: var(--theme-primary-bg, #cce5ff); color: var(--theme-primary-text, #004085); } + .classification-bankruptcy { background: var(--theme-danger-bg, #f8d7da); color: var(--theme-danger-text, #721c24); } .classification-spam { background: #343a40; color: #fff; } - .classification-general { background: #e9ecef; color: #495057; } + .classification-general { background: var(--theme-surface-soft, #e9ecef); color: var(--theme-text, #495057); } [data-bs-theme="dark"] .classification-badge { opacity: 0.9; @@ -359,13 +359,13 @@ .email-actions .btn-danger-soft { background: rgba(220, 53, 69, 0.08); - color: #a72a37; + color: var(--theme-danger-text, #a72a37); border-color: rgba(220, 53, 69, 0.28); } .email-actions .btn-danger-soft:hover { background: rgba(220, 53, 69, 0.14); - color: #8e1f2d; + color: var(--theme-danger-text, #8e1f2d); } .email-body { @@ -390,7 +390,7 @@ height: min(72vh, 980px); border: 1px solid rgba(0,0,0,0.08); border-radius: 10px; - background: #ffffff; + background: var(--theme-surface, #ffffff); display: block; } @@ -813,7 +813,7 @@ /* Workflow Step Builder */ .workflow-step-item { - background: white; + background: var(--theme-surface, white); border: 2px solid #e0e0e0; border-radius: 8px; margin-bottom: 0.75rem; diff --git a/app/emails/frontend/emails_v2.html b/app/emails/frontend/emails_v2.html index 6711c6f..7b8a54f 100644 --- a/app/emails/frontend/emails_v2.html +++ b/app/emails/frontend/emails_v2.html @@ -429,7 +429,7 @@ } .emails-v2-inline-status.error { - color: #b42318; + color: var(--theme-danger-text, #b42318); } .emails-v2-inline-status.success { diff --git a/app/jobs/process_subscriptions.py b/app/jobs/process_subscriptions.py index 035b2db..b2da9e1 100644 --- a/app/jobs/process_subscriptions.py +++ b/app/jobs/process_subscriptions.py @@ -5,6 +5,7 @@ Creates ordre drafts and advances subscription periods Runs daily at 04:00 """ +from app.services.invoice_fee_service import with_invoice_fee import logging from datetime import datetime, date import json @@ -311,6 +312,7 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int: f"Subscription IDs: {', '.join(str(sid) for sid in source_subscription_ids)}" ) + ordre_lines = with_invoice_fee(ordre_lines, customer_id, cursor=cursor, native=True) insert_query = """ INSERT INTO ordre_drafts ( title, diff --git a/app/models/schemas.py b/app/models/schemas.py index cb2005d..6f38ac5 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -39,7 +39,8 @@ class CustomerUpdate(BaseModel): standard_hourly_rate: Optional[float] = None special_freight_price: Optional[float] = None supplier_service_enrolled: Optional[bool] = None - invoice_fee_amount: Optional[float] = None + invoice_fee_amount: Optional[float] = Field(None, ge=0, allow_inf_nan=False) + invoice_fee_exempt: Optional[bool] = None class Customer(CustomerBase): @@ -325,6 +326,8 @@ class TodoStep(TodoStepBase): is_next: bool = False created_by_user_id: Optional[int] = None created_by_name: Optional[str] = None + assigned_to_user_id: Optional[int] = None + assigned_to_name: Optional[str] = None created_at: datetime completed_by_user_id: Optional[int] = None completed_by_name: Optional[str] = None diff --git a/app/modules/also/backend/service.py b/app/modules/also/backend/service.py index 565ca84..eff0b13 100644 --- a/app/modules/also/backend/service.py +++ b/app/modules/also/backend/service.py @@ -1,3 +1,4 @@ +from app.services.invoice_fee_service import with_invoice_fee import hashlib import json import logging @@ -2094,6 +2095,7 @@ class AlsoService: ) draft_id = int(draft["id"]) else: + draft_lines = with_invoice_fee(draft_lines, customer_id) draft = execute_query_single( """ INSERT INTO ordre_drafts ( diff --git a/app/modules/bottom_bar/backend/service.py b/app/modules/bottom_bar/backend/service.py index 8d774e0..c18c70c 100644 --- a/app/modules/bottom_bar/backend/service.py +++ b/app/modules/bottom_bar/backend/service.py @@ -506,7 +506,7 @@ def get_active_timer(user_id: Optional[int]) -> Dict[str, Any]: t.sag_id, s.titel AS sag_navn, t.start_tid, - GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::int, 0) AS elapsed + GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::int - COALESCE(t.pause_total_seconds, 0)::int, 0) AS elapsed FROM tmodule_times t LEFT JOIN sag_sager s ON s.id = t.sag_id WHERE t.medarbejder_id = %s @@ -560,7 +560,7 @@ def get_own_timer_snapshot(user_id: Optional[int], paused_limit: int = 10) -> Di t.start_tid, t.slut_tid, GREATEST( - EXTRACT(EPOCH FROM (NOW() - COALESCE(t.start_tid, NOW())))::int, + EXTRACT(EPOCH FROM (COALESCE(t.paused_at, NOW()) - COALESCE(t.start_tid, NOW())))::int, 0 ) AS elapsed_seconds, COALESCE(t.pause_total_seconds, 0)::int AS pause_total_seconds diff --git a/app/modules/calendar/backend/router.py b/app/modules/calendar/backend/router.py index de230d7..7d51481 100644 --- a/app/modules/calendar/backend/router.py +++ b/app/modules/calendar/backend/router.py @@ -76,6 +76,7 @@ def _build_ical_response(events: list[dict], now: datetime) -> Response: f"UID:{uid}", f"DTSTAMP:{_format_ical_dt(now)}", f"DTSTART:{_format_ical_dt(start_value)}", + *([f"DTEND:{_format_ical_dt(datetime.fromisoformat(event['end']))}"] if event.get("end") else []), f"SUMMARY:{summary}", f"DESCRIPTION:{description}", "END:VEVENT", @@ -157,6 +158,8 @@ def _get_calendar_events( "meeting", "technician_visit", "obs", + "quick_planned", + "quick_registered", } requested_types = { t.strip() for t in (types.split(",") if types else []) if t.strip() @@ -322,6 +325,35 @@ def _get_calendar_events( ) ) + quick_states = [state for state in ("planned", "registered") + if not normalized_types or f"quick_{state}" in normalized_types] + if quick_states: + query = """SELECT q.*, c.name AS customer_name, COALESCE(u.full_name,u.username) AS user_name + FROM quick_time_items q LEFT JOIN customers c ON c.id=q.customer_id + JOIN users u ON u.user_id=q.user_id + WHERE q.state=ANY(%s) AND q.starts_at<%s AND q.ends_at>%s""" + params = [quick_states, end_dt, start_dt] + if only_mine and resolved_user_id is not None: + query += " AND q.user_id=%s" + params.append(resolved_user_id) + if customer_id: + query += " AND q.customer_id=%s" + params.append(customer_id) + for row in execute_query(query, tuple(params)) or []: + kind = f"quick_{row['state']}" + label = "Planlagt tid" if row['state'] == 'planned' else "Brugt tid" + event = _build_event(kind, f"{label}: {row['description']} · {row['user_name']}", + row['starts_at'], + f"/sag/new?quick_plan={row['id']}" if row['state'] == 'planned' else '/timetracking', + {"reference_id": row['id'], "reference_type": "quick_time", + "event_kind": kind, "user_id": row['user_id'], + "customer_id": row.get('customer_id'), "customer_name": row.get('customer_name')}) + event['end'] = row['ends_at'].isoformat() + event['backgroundColor'] = '#336699' if row['state'] == 'planned' else '#7c3aed' + event['borderColor'] = event['backgroundColor'] + event['textColor'] = '#ffffff' + events.append(event) + events.sort(key=lambda item: item.get("start") or "") return events diff --git a/app/modules/calendar/templates/index.html b/app/modules/calendar/templates/index.html index cf0e25b..0df1342 100644 --- a/app/modules/calendar/templates/index.html +++ b/app/modules/calendar/templates/index.html @@ -161,7 +161,7 @@ border: 1px solid var(--calendar-border); padding: 0.5rem 0.75rem; width: 100%; - background: #ffffff; + background: var(--theme-surface, #ffffff); color: var(--calendar-ink); } @@ -175,7 +175,7 @@ display: inline-flex; align-items: center; gap: 0.5rem; - background: #ffffff; + background: var(--theme-surface, #ffffff); border: 1px solid var(--calendar-border); border-radius: 8px; padding: 0.35rem 0.6rem; @@ -233,7 +233,7 @@ top: calc(100% + 6px); left: 0; right: 0; - background: #ffffff; + background: var(--theme-surface, #ffffff); border: 1px solid var(--calendar-border); border-radius: 12px; box-shadow: 0 12px 24px rgba(15, 23, 42, 0.12); @@ -378,7 +378,7 @@ .event-meeting { background: rgba(15, 76, 117, 0.18); - color: #0f4c75; + color: var(--theme-primary-text, #0f4c75); } .event-technician_visit { @@ -459,6 +459,8 @@
+ +
-
+
Planlagt tid
Brugt tid
Deadline
Deferred
Møde
diff --git a/app/modules/fedex/frontend/fedex_overview.html b/app/modules/fedex/frontend/fedex_overview.html index d9a6c38..7bccaa2 100644 --- a/app/modules/fedex/frontend/fedex_overview.html +++ b/app/modules/fedex/frontend/fedex_overview.html @@ -89,7 +89,7 @@ .fedex-status.draft { background: rgba(108, 117, 125, 0.15); border-color: rgba(108, 117, 125, 0.3); color: #5f6b76; } .fedex-status.submitted, .fedex-status.booked { background: rgba(13, 110, 253, 0.12); border-color: rgba(13, 110, 253, 0.3); color: #0a58ca; } .fedex-status.in_transit { background: rgba(255, 193, 7, 0.15); border-color: rgba(255, 193, 7, 0.35); color: #996f00; } - .fedex-status.delivered { background: rgba(25, 135, 84, 0.14); border-color: rgba(25, 135, 84, 0.3); color: #146c43; } + .fedex-status.delivered { background: rgba(25, 135, 84, 0.14); border-color: rgba(25, 135, 84, 0.3); color: var(--theme-success-text, #146c43); } .fedex-status.cancelled, .fedex-status.failed { background: rgba(220, 53, 69, 0.14); border-color: rgba(220, 53, 69, 0.3); color: #b02a37; } .fedex-row-title { diff --git a/app/modules/hardware/templates/detail.html b/app/modules/hardware/templates/detail.html index ce34330..bd19571 100644 --- a/app/modules/hardware/templates/detail.html +++ b/app/modules/hardware/templates/detail.html @@ -27,7 +27,7 @@ } .action-card { - background: white; + background: var(--theme-surface, white); padding: 1rem; border-radius: 8px; text-align: center; @@ -56,7 +56,7 @@ .switch-port-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .85rem; } .switch-port-grid { display: grid; grid-template-columns: repeat(24, minmax(44px, 1fr)); gap: .35rem; } - .switch-port-button { min-height: 58px; border-radius: .35rem; border: 2px solid #aeb7c1; background: #f4f6f8; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; } + .switch-port-button { min-height: 58px; border-radius: .35rem; border: 2px solid #aeb7c1; background: var(--theme-surface-soft, #f4f6f8); color: var(--theme-text, #263645); font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; } .switch-port-button:hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); } .switch-port-button.connected { background:#198754; border-color:#146c43; color:#fff; } .switch-port-button.hardware-linked { background:#6f42c1; border-color:#59359f; color:#fff; } @@ -79,7 +79,7 @@ top: 0; bottom: 0; width: 2px; - background: #e9ecef; + background: var(--theme-surface-soft, #e9ecef); } .timeline-item { position: relative; @@ -92,7 +92,7 @@ width: 0.8rem; height: 0.8rem; border-radius: 50%; - background: white; + background: var(--theme-surface, white); border: 2px solid var(--accent); } .timeline-item.active .timeline-marker { @@ -1230,7 +1230,7 @@ -
+
{% macro render_location_option(node, depth) %}