Compare commits

...

4 Commits

Author SHA1 Message Date
Christian
06dffbc521 release: v2.8.17 2026-09-24 09:15:38 +02:00
Christian
28a92eeb55 Add tests for case tree structure, timeline UI, and relation tree
- Implement tests to ensure the case tree contains parent and child branches while preventing cycles.
- Verify that the timeline UI utilizes tree filters and cursor paging.
- Confirm that the relation tree is displayed as a separate tab with appropriate search filters and inline module content.

Enhance time tracking with prepaid card usage alerts

- Create a migration to track alerts for prepaid card usage at 80% and 100%.
- Implement triggers to generate cases when usage thresholds are reached, ensuring proper notifications and case management.

Introduce audit logging for time entry edits

- Modify the sync log to include time entry updates in the audit stream without altering existing records.

Establish tracking for vTiger case imports and exports

- Create tables to track cases imported from vTiger into Hub and exports from Hub to vTiger, ensuring idempotency and proper reference management.
2026-09-24 09:12:25 +02:00
Christian
81b8229db1 feat: Add Kuando Busylight integration and enhance case creation UI
- Introduced Kuando Busylight integration for centralized presence management.
- Updated case creation UI to support new modules and improved type handling.
- Added personal Buzzwords visibility preferences for users.
- Implemented file upload functionality directly from the case topbar.
- Enhanced validation and user experience in case creation process.
- Created migration for user-specific module preferences.
- Updated tests to cover new features and ensure functionality.
2026-09-23 00:23:30 +02:00
Christian
abfb945679 feat(planner): add overdue cases handling and improve case rendering
- Introduced `overdueCases` state to track overdue cases in the planner.
- Enhanced `loadCases` function to fetch and store overdue cases.
- Updated `renderCases` to display overdue cases with appropriate labels.
- Modified case card rendering to highlight overdue cases.

fix(telefoni): ensure WebSocket connection on DOMContentLoaded

- Adjusted WebSocket connection logic to handle cases where the script loads after DOMContentLoaded.

test(tests): add tests for case creation and internal ticket handling

- Added tests to ensure ticket creation commits all optional data.
- Implemented tests for internal ticket creation, ensuring correct customer and user assignments.

feat(migrations): add new tables for email configuration and case assignments

- Created `email_connection_configurations` table for managing email settings.
- Introduced `sag_case_assignees` table to handle individual case assignments.
- Added `sag_case_details` table for structured case type details.
- Updated existing case types to align with new templates and added support for internal tickets.

fix(timetracking): allow manual time creation without case customer

- Modified manual time creation to support entries without a customer.
- Updated settlement options to recommend internal settlement for customerless time.

feat(v4): enhance case detail template with new features

- Added new features to the case detail template, including compact icons and inline relation creation.
- Improved comment actions and time tracking functionalities.
2026-09-20 03:33:10 +02:00
50 changed files with 29983 additions and 402 deletions

View File

@ -0,0 +1,21 @@
# BMC Hub v2.8.17
## Sager, tid og vTiger
- Udvider sagsvisningen med sagstræ, samlet historik og relationstræ.
- Forbedrer tidskø, manuel tidsregistrering og revisionsspor for ændrede tidsregistreringer.
- Tilføjer idempotent import af individuelle vTiger-sager til Hub og eksportsporing fra Hub til vTiger.
## Drift og kundeplaner
- Tilføjer den fremtidige plan for **Eskaleringsplan** under kunden.
- Planen fastslår, at den eksisterende Drift-fane, alarmer, integrationer, mappings og sagslinks bevares ved en senere implementering.
## Øvrigt
- Tilføjer opfølgning ved 80 % og 100 % forbrug af klippekort.
- Indeholder Kuando Busylight-integration og forbedringer af sagsoprettelse og planner.
## Database
Kør migrationerne `1043` til `1046` i rækkefølge før eller sammen med opstart af versionen.

View File

@ -1 +1 @@
2.8.16
2.8.17

View File

@ -221,7 +221,7 @@ class UserProfileUpdate(BaseModel):
def _allowed_case_types() -> list[str]:
fallback = ["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]
fallback = ["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"]
try:
rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or []
if rows:
@ -229,7 +229,7 @@ def _allowed_case_types() -> list[str]:
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 [])))
return list(dict.fromkeys(values))
except Exception:
logger.warning("Could not load configured case types for profile preference", exc_info=True)
return fallback

View File

@ -181,6 +181,8 @@ class Settings(BaseSettings):
VTIGER_URL: str = ""
VTIGER_USERNAME: str = ""
VTIGER_API_KEY: str = ""
# VTiger Cases custom checkbox/flag used after a case has been imported into Hub.
VTIGER_CASE_HUBBED_FIELD: str = "cf_cases_hubbet"
# Data Consistency Settings
VTIGER_SYNC_ENABLED: bool = True

View File

@ -110,6 +110,7 @@ def _hours_for_prepaid_card(row: Dict[str, Any], rounding_minutes: int) -> float
async def list_hub_time_queue(
customer_id: Optional[int] = Query(None, gt=0),
status: Optional[str] = Query(None),
case_state: Optional[str] = Query(None),
billable: Optional[bool] = Query(None),
q: Optional[str] = Query(None),
limit: int = Query(500, ge=1, le=2000),
@ -134,6 +135,15 @@ async def list_hub_time_queue(
conditions.append("t.status = %s")
params.append(status)
if case_state == "closed":
conditions.append("LOWER(TRIM(COALESCE(s.status, ''))) IN ('lukket', 'løst', 'closed', 'resolved', 'afsluttet', 'done')")
elif case_state == "open":
conditions.append("s.id IS NOT NULL AND LOWER(TRIM(COALESCE(s.status, ''))) NOT IN ('lukket', 'løst', 'closed', 'resolved', 'afsluttet', 'done')")
elif case_state == "no_case":
conditions.append("s.id IS NULL")
elif case_state:
raise HTTPException(status_code=400, detail="Ugyldigt sagsfilter")
if billable is not None:
conditions.append("COALESCE(t.billable, true) = %s")
params.append(billable)
@ -779,6 +789,7 @@ def _selected_time_entries(ids: List[int]) -> List[Dict[str, Any]]:
t.original_hours, t.approved_hours, t.faktisk_tid_min,
t.fakturerbar_tid_min, t.round_block_min, t.worked_date, t.description,
COALESCE(c.title, s.titel, 'Tidsregistrering') AS case_title,
s.status AS case_status,
COALESCE(case_customer.name, effective_customer.name) AS customer_name,
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS hub_customer_id,
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS billing_customer_id
@ -856,8 +867,10 @@ def _build_settlement_preview(payload: SettlementRequest) -> Dict[str, Any]:
item = {
"id": int(row["id"]),
"title": row.get("case_title") or "Tidsregistrering",
"case_status": row.get("case_status"),
"customer_name": row.get("customer_name") or "Ukendt kunde",
"hours": hours,
"actual_hours": float(row.get("faktisk_tid_min") or round(float(row.get("original_hours") or 0) * 60)) / 60.0,
"method": method,
"method_label": _settlement_label(method),
"valid": True,
@ -908,8 +921,10 @@ def _build_settlement_preview(payload: SettlementRequest) -> Dict[str, Any]:
"SELECT name, COALESCE(standard_hourly_rate, %s) AS hourly_rate FROM customers WHERE id = %s",
(settings.TIMETRACKING_DEFAULT_HOURLY_RATE, customer_id),
) or {}
# Invoice quantities must match the draft that will be created below.
# Per-entry rounding only applies when debiting a prepaid card.
hours = sum(
_hours_for_prepaid_card(row, int(card.get("rounding_minutes") or 30))
float(row.get("approved_hours") or row.get("original_hours") or 0)
for row in group_rows
)
rate = float(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE)

View File

@ -19,14 +19,15 @@
@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-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}
</style>
<main class="container-fluid py-4 time-queue">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3"><div><div class="text-primary text-uppercase fw-semibold small">Økonomi</div><h1 class="h2 mb-1">Tidskø til afregning</h1><p class="text-muted mb-0">Gennemgå tid, vælg afregning og opret først ordrekladder efter en tydelig forhåndsvisning.</p></div><button class="btn btn-outline-secondary" id="reloadBtn"><i class="bi bi-arrow-clockwise me-1"></i>Opdatér</button></div>
<div id="messageArea" aria-live="polite"></div>
<section class="queue-summary p-3 mb-3"><div class="d-flex flex-wrap gap-4 align-items-center"><div class="queue-stat"><div class="small text-muted">Registreringer</div><strong id="statEntries">–</strong></div><div class="queue-stat"><div class="small text-muted">Timer i alt</div><strong id="statHours">–</strong></div><div class="queue-stat"><div class="small text-muted">Klar til faktura</div><strong id="statInvoice">–</strong></div><div class="queue-stat"><div class="small text-muted">Udvalgt</div><strong id="statSelected">0</strong></div><div class="ms-md-auto small text-muted"><i class="bi bi-shield-check text-success me-1"></i>Intet afregnes uden forhåndsvisning.</div></div></section>
<section class="card mb-3"><div class="card-body"><div class="row g-2 align-items-end"><div class="col-12 col-md-3"><label class="form-label">Firma</label><select id="filterCustomer" class="form-select"><option value="">Alle firmaer</option></select></div><div class="col-6 col-md-2"><label class="form-label">Status</label><select id="filterStatus" class="form-select"><option value="">Alle statusser</option><option value="pending">Afventer</option><option value="approved">Godkendt</option><option value="rejected">Afvist</option></select></div><div class="col-6 col-md-2"><label class="form-label">Fakturerbar</label><select id="filterBillable" class="form-select"><option value="">Alle</option><option value="true">Ja</option><option value="false">Nej</option></select></div><div class="col-12 col-md-4"><label class="form-label">Søg</label><input id="filterQuery" class="form-control" placeholder="Kunde, sag eller beskrivelse"></div><div class="col-12 col-md-1 d-grid"><button class="btn btn-outline-secondary" id="clearFiltersBtn">Ryd</button></div></div></div></section>
<section class="card mb-3"><div class="card-body"><div class="d-flex justify-content-between flex-wrap gap-2 mb-3"><div><h2 class="h5 mb-0">Afregning for valgte tidsregistreringer</h2><div class="small text-muted">Vælg type og eventuelt klippekort eller aftale.</div></div><span class="badge text-bg-primary align-self-center" id="selectedBadge">0 valgt</span></div><div class="row g-2 align-items-end"><div class="col-12 col-lg-3"><label class="form-label">Afregningstype</label><select class="form-select" id="settlementMethod"><option value="">Behold den enkelte tids afregningstype</option><option value="invoice">Faktura</option><option value="prepaid">Klippekort</option><option value="subscription">Abonnement / fast pris</option><option value="internal">Intern tid</option><option value="non_billable">Ikke-fakturerbar tid</option></select></div><div class="col-12 col-lg-3"><label class="form-label">Klippekort</label><select class="form-select" id="prepaidCard" disabled><option value="">Vælg først tider fra ét firma</option></select><div class="form-text" id="prepaidHelp">Vælg tidsregistreringer for at se kundens klippekort.</div></div><div class="col-12 col-lg-2"><label class="form-label">Aftale-ID</label><input id="agreementId" class="form-control" type="number" min="1" placeholder="Kun abonnement"></div><div class="col-12 col-lg-4 d-flex gap-2"><button class="btn btn-outline-primary flex-grow-1" id="setMethodBtn">Gem afregning</button><button class="btn btn-primary flex-grow-1" id="previewBtn"><i class="bi bi-eye me-1"></i>Forhåndsvis</button></div></div></div></section>
<section id="previewPanel" class="card border-primary mb-3 d-none"><div class="card-header bg-primary-subtle d-flex justify-content-between align-items-center"><div><strong>Forhåndsvisning af afregning</strong><div class="small text-muted">Kontrollér beløb og fejl før afregningen gennemføres.</div></div><button class="btn btn-sm btn-outline-secondary" id="closePreviewBtn">Luk</button></div><div class="card-body" id="previewBody"></div><div class="card-footer text-end"><button class="btn btn-success" id="settleBtn"><i class="bi bi-check2-circle me-1"></i>Gennemfør afregning</button></div></section>
<section class="queue-summary p-3 mb-3"><div class="d-flex flex-wrap gap-4 align-items-center"><div class="queue-stat"><div class="small text-muted">Registreringer</div><strong id="statEntries">–</strong></div><div class="queue-stat"><div class="small text-muted">Timer i alt</div><strong id="statHours">–</strong></div><div class="queue-stat"><div class="small text-muted">Klar til faktura</div><strong id="statInvoice">–</strong></div><div class="queue-stat"><div class="small text-muted">Sager løst/lukket</div><strong id="statClosedCases">–</strong></div><div class="queue-stat"><div class="small text-muted">Udvalgt</div><strong id="statSelected">0</strong></div><div class="ms-md-auto small text-muted"><i class="bi bi-shield-check text-success me-1"></i>Forhåndsvisning kontrollerer; den ændrer ikke data.</div></div></section>
<section class="card mb-3"><div class="card-body"><div class="row g-2 align-items-end"><div class="col-12 col-md-3"><label class="form-label">Firma</label><select id="filterCustomer" class="form-select"><option value="">Alle firmaer</option></select></div><div class="col-6 col-md-2"><label class="form-label">Tidsstatus</label><select id="filterStatus" class="form-select"><option value="">Alle statusser</option><option value="pending">Afventer</option><option value="approved">Godkendt</option><option value="rejected">Afvist</option></select></div><div class="col-6 col-md-2"><label class="form-label">Sagens status</label><select id="filterCaseState" class="form-select"><option value="">Alle sager (inkl. løst/lukket)</option><option value="closed">Løst eller lukket</option><option value="open">Åbne sager</option><option value="no_case">Uden sag</option></select></div><div class="col-6 col-md-2"><label class="form-label">Fakturerbar</label><select id="filterBillable" class="form-select"><option value="">Alle</option><option value="true">Ja</option><option value="false">Nej</option></select></div><div class="col-12 col-md-2"><label class="form-label">Søg</label><input id="filterQuery" class="form-control" placeholder="Kunde, sag eller beskrivelse"></div><div class="col-12 col-md-1 d-grid"><button class="btn btn-outline-secondary" id="clearFiltersBtn">Ryd</button></div></div></div></section>
<section class="card mb-3"><div class="card-body"><div class="d-flex justify-content-between flex-wrap gap-2 mb-3"><div><h2 class="h5 mb-0">Afregning for valgte tidsregistreringer</h2><div class="small text-muted">Vælg type og eventuelt klippekort eller aftale. Løst/lukket sagsstatus forhindrer ikke afregning.</div></div><span class="badge text-bg-primary align-self-center" id="selectedBadge">0 valgt</span></div><div class="row g-2 align-items-end"><div class="col-12 col-lg-3"><label class="form-label">Afregningstype</label><select class="form-select" id="settlementMethod"><option value="">Behold den enkelte tids afregningstype</option><option value="invoice">Faktura</option><option value="prepaid">Klippekort</option><option value="subscription">Abonnement / fast pris</option><option value="internal">Intern tid</option><option value="non_billable">Ikke-fakturerbar tid</option></select></div><div class="col-12 col-lg-3"><label class="form-label">Klippekort</label><select class="form-select" id="prepaidCard" disabled><option value="">Vælg først tider fra ét firma</option></select><div class="form-text" id="prepaidHelp">Vælg tidsregistreringer for at se kundens klippekort.</div></div><div class="col-12 col-lg-2"><label class="form-label">Aftale-ID</label><input id="agreementId" class="form-control" type="number" min="1" placeholder="Kun abonnement"></div><div class="col-12 col-lg-4 d-flex gap-2"><button class="btn btn-outline-primary flex-grow-1" id="setMethodBtn">Gem afregning</button><button class="btn btn-primary flex-grow-1" id="previewBtn"><i class="bi bi-eye me-1"></i>Forhåndsvis</button></div></div></div></section>
<section id="previewPanel" class="card border-primary mb-3 d-none"><div class="card-header bg-primary-subtle d-flex justify-content-between align-items-center"><div><strong>Forhåndsvisning af afregning</strong><div class="small text-muted">Dette er en kontrol uden ændringer. Først når du trykker “Gennemfør”, gemmes afregningen.</div></div><button class="btn btn-sm btn-outline-secondary" id="closePreviewBtn">Luk</button></div><div class="card-body" id="previewBody"></div><div class="card-footer"><div class="small text-muted mb-2">Faktura opretter en lokal ordrekladde. Klippekort trækker timer. Fastpris knytter timer til aftalen. Intet sendes eller bogføres i e-conomic her.</div><div class="text-end"><button class="btn btn-success" id="settleBtn"><i class="bi bi-check2-circle me-1"></i>Gennemfør afregning</button></div></div></section>
<div class="modal fade" id="caseDetailModal" tabindex="-1" aria-labelledby="caseDetailTitle" aria-hidden="true"><div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content"><div class="modal-header"><div><div class="small text-primary text-uppercase fw-semibold">Sag uden at forlade tidskøen</div><h2 class="modal-title h4 mb-0" id="caseDetailTitle">Sagsdetaljer</h2></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body" id="caseDetailBody"></div><div class="modal-footer"><a class="btn btn-outline-primary" id="openCaseNewTab" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn sag i ny fane</a><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button></div></div></div></div>
<section id="queueContent" aria-live="polite"></section>
<div class="queue-actions card d-none" id="selectionActions"><div class="card-body py-2 d-flex flex-wrap justify-content-between align-items-center gap-2"><div><strong id="selectionText">0 tidsregistreringer valgt</strong><span class="small text-muted ms-2">Vælg forhåndsvisning før afregning.</span></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-secondary" id="clearSelectionBtn">Fravælg</button><button class="btn btn-sm btn-primary" id="stickyPreviewBtn">Forhåndsvis afregning</button></div></div></div>
@ -41,25 +42,34 @@
async function api(url,options={}){const r=await fetch(url,{headers:{'Content-Type':'application/json'},...options});const d=await r.json().catch(()=>({}));if(!r.ok){const e=new Error(typeof d.detail==='object'?(d.detail.message||'Handlingen kunne ikke gennemføres'):(d.detail||'Handlingen kunne ikke gennemføres'));e.payload=d;throw e}return d}
function payload(){const method=$('settlementMethod').value,card=Number($('prepaidCard').value),agreement=Number($('agreementId').value);return {ids:ids(),...(method?{billing_method:method}:{}),...(card?{prepaid_card_id:card}:{}),...(agreement?{fixed_price_agreement_id:agreement}:{})}}
function status(v){return `<span class="badge ${v==='approved'?'text-bg-success':v==='rejected'?'text-bg-danger':'text-bg-warning'}">${esc(labels[v]||v||'Ukendt')}</span>`} function method(v){const m=norm(v),cls=m==='invoice'?'text-bg-primary':m==='prepaid'?'text-bg-purple':m==='subscription'?'text-bg-success':'text-bg-secondary';return `<span class="badge ${cls}">${esc(labels[m]||m)}</span>`}
function renderStats(){const selected=state.items.filter(x=>state.selected.has(Number(x.id)));$('statEntries').textContent=state.items.length;$('statHours').textContent=`${h(state.items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0))} t`;$('statInvoice').textContent=state.items.filter(x=>norm(x.billing_method)==='invoice'&&x.billable!==false).length;$('statSelected').textContent=selected.length;$('selectedBadge').textContent=`${selected.length} valgt`;$('selectionText').textContent=`${selected.length} tidsregistrering${selected.length===1?'':'er'} valgt`;$('selectionActions').classList.toggle('d-none',!selected.length)}
function isClosedCase(value){return ['lukket','løst','closed','resolved','afsluttet','done'].includes(String(value||'').trim().toLocaleLowerCase('da-DK'))} function caseStatus(v){if(!v)return '<span class="badge text-bg-light border">Uden sag</span>';return `<span class="badge ${isClosedCase(v)?'case-closed-badge':'case-open-badge'}">Sag: ${esc(v)}</span>`}
function renderStats(){const selected=state.items.filter(x=>state.selected.has(Number(x.id)));$('statEntries').textContent=state.items.length;$('statHours').textContent=`${h(state.items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0))} t`;$('statInvoice').textContent=state.items.filter(x=>norm(x.billing_method)==='invoice'&&x.billable!==false).length;$('statClosedCases').textContent=state.items.filter(x=>isClosedCase(x.case_status)).length;$('statSelected').textContent=selected.length;$('selectedBadge').textContent=`${selected.length} valgt`;$('selectionText').textContent=`${selected.length} tidsregistrering${selected.length===1?'':'er'} valgt`;$('selectionActions').classList.toggle('d-none',!selected.length)}
function selectedRows(){return state.items.filter(x=>state.selected.has(Number(x.id)))}
function selectedCardCustomerIds(){return [...new Set(selectedRows().map(x=>Number(x.billing_customer_id||x.hub_customer_id||0)).filter(Boolean))]}
function selectedHours(){return selectedRows().reduce((total,x)=>total+Number(x.approved_hours||x.original_hours||0),0)}
function actualHours(rows){return rows.reduce((total,x)=>total+Number(x.faktisk_tid_min!=null?x.faktisk_tid_min:Number(x.original_hours||0)*60)/60,0)}
function cardHours(rows,block){const safeBlock=Math.max(1,Number(block)||30);return rows.reduce((total,x)=>{const minutes=Math.max(0,Math.round(Number(x.faktisk_tid_min!=null?x.faktisk_tid_min:Number(x.original_hours||0)*60)));return total+(minutes?Math.ceil(minutes/safeBlock)*safeBlock/60:0)},0)}
function renderPrepaidHelp(){const selected=selectedRows(),customers=selectedCardCustomerIds(),card=state.cards.find(x=>Number(x.id)===Number($('prepaidCard').value)),storedHours=selectedHours();if(!selected.length){$('prepaidHelp').textContent='Vælg tidsregistreringer for at se kundens klippekort.';return}if(customers.length!==1){$('prepaidHelp').innerHTML='<span class="text-danger">Vælg kun tider fra ét firma for at bruge ét klippekort.</span>';return}const company=selected[0].customer_name||'den valgte kunde';if(!card){$('prepaidHelp').textContent=`${esc(company)} · Fakturerbar tid: ${h(storedHours)} timer. Vælg et klippekort for at se kortets afrunding.`;return}const block=Number(card.rounding_minutes)||30,actual=actualHours(selected),hours=cardHours(selected,block),remaining=Number(card.remaining_hours||0),after=remaining-hours;$('prepaidHelp').innerHTML=`${esc(company)} · Faktisk tid: <strong>${h(actual)} t</strong> → klippes som <strong>${h(hours)} t</strong> <span class="text-muted">(${block} min. afrunding pr. registrering)</span>. Kortet har ${h(remaining)} t tilbage → <strong class="${after<0?'text-danger':'text-success'}">${h(after)} t efter afregning</strong>.`}
function render(){renderStats();if(!state.items.length){$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><i class="bi bi-check2-circle fs-2 d-block text-success mb-2"></i>Ingen tidsregistreringer matcher dine filtre.</div>';return}const groups={};state.items.forEach(x=>{const key=`${x.customer_id||0}:${x.customer_name||'Ukendt kunde'}`;(groups[key]??=[]).push(x)});$('queueContent').innerHTML=Object.values(groups).map(items=>{const f=items[0],total=items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0);return `<article class="time-group mb-3"><header class="px-3 py-2 d-flex justify-content-between flex-wrap gap-2"><div><strong>${esc(f.customer_name||'Ukendt kunde')}</strong><span class="small text-muted ms-2">${items.length} registreringer · ${h(total)} timer</span></div><button class="btn btn-sm btn-link customer-select" data-ids="${items.map(x=>x.id).join(',')}">Vælg kunde</button></header>${items.map(x=>{const id=Number(x.id),m=norm(x.billing_method);return `<div class="time-row p-3" data-method="${esc(m)}"><div class="row align-items-center g-2"><div class="col-auto"><input class="form-check-input row-check" type="checkbox" data-id="${id}" ${state.selected.has(id)?'checked':''}></div><div class="col-12 col-lg-4"><div class="fw-semibold">${x.sag_id?`<button type="button" class="btn btn-link p-0 text-decoration-none fw-semibold case-details" data-sag-id="${Number(x.sag_id)}">#${Number(x.sag_id)} · ${esc(x.case_title)}</button>`:esc(x.case_title||'Ingen sag')}</div><div class="small text-muted">${esc(x.contact_name||'Ingen kontakt')} · ${esc(x.employee_name||'Ukendt medarbejder')}</div></div><div class="col-6 col-lg-2"><div class="small text-muted">Dato og status</div>${esc(x.worked_date||'Ingen dato')} ${status(x.status)}</div><div class="col-6 col-lg-2"><div class="small text-muted">Tid</div><strong>${h(x.approved_hours||x.original_hours)} t</strong><span class="small text-muted"> · ${esc(x.work_type||x.entry_type||'arbejde')}</span></div><div class="col-8 col-lg-2"><div class="small text-muted">Afregning</div>${method(m)} ${x.billable===false?'<span class="small text-muted">ikke fakturerbar</span>':''}</div><div class="col-4 col-lg-2 text-lg-end"><button class="btn btn-sm btn-outline-secondary select-one" data-id="${id}">Gennemgå</button></div></div>${x.description?`<div class="small text-muted mt-2 ps-lg-4">${esc(x.description)}</div>`:'<div class="small text-warning mt-2 ps-lg-4"><i class="bi bi-exclamation-triangle me-1"></i>Mangler beskrivelse</div>'}</div>`}).join('')}</article>`}).join('');document.querySelectorAll('.row-check').forEach(e=>e.addEventListener('change',()=>{const id=Number(e.dataset.id);e.checked?state.selected.add(id):state.selected.delete(id);selectionChanged()}));document.querySelectorAll('.case-details').forEach(e=>e.addEventListener('click',()=>openCaseDetails(Number(e.dataset.sagId))));document.querySelectorAll('.select-one').forEach(e=>e.addEventListener('click',()=>{state.selected=new Set([Number(e.dataset.id)]);selectionChanged();$('settlementMethod').scrollIntoView({behavior:'smooth',block:'center'})}));document.querySelectorAll('.customer-select').forEach(e=>e.addEventListener('click',()=>{e.dataset.ids.split(',').forEach(id=>state.selected.add(Number(id)));selectionChanged()}))}
function url(){const p=new URLSearchParams({limit:'500'});if($('filterCustomer').value)p.set('customer_id',$('filterCustomer').value);if($('filterStatus').value)p.set('status',$('filterStatus').value);if($('filterBillable').value)p.set('billable',$('filterBillable').value);if($('filterQuery').value.trim())p.set('q',$('filterQuery').value.trim());return `/api/v1/economy/time-queue?${p}`}
function render(){renderStats();if(!state.items.length){$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><i class="bi bi-check2-circle fs-2 d-block text-success mb-2"></i>Ingen tidsregistreringer matcher dine filtre.</div>';return}const groups={};state.items.forEach(x=>{const key=`${x.customer_id||0}:${x.customer_name||'Ukendt kunde'}`;(groups[key]??=[]).push(x)});$('queueContent').innerHTML=Object.values(groups).map(items=>{const f=items[0],total=items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0);return `<article class="time-group mb-3"><header class="px-3 py-2 d-flex justify-content-between flex-wrap gap-2"><div><strong>${esc(f.customer_name||'Ukendt kunde')}</strong><span class="small text-muted ms-2">${items.length} registreringer · ${h(total)} timer</span></div><button class="btn btn-sm btn-outline-primary customer-select" data-ids="${items.map(x=>x.id).join(',')}">Vælg alle ${items.length} tider</button></header>${items.map(x=>{const id=Number(x.id),m=norm(x.billing_method);return `<div class="time-row p-3" data-method="${esc(m)}"><div class="row align-items-center g-2"><div class="col-auto"><input class="form-check-input row-check" type="checkbox" data-id="${id}" ${state.selected.has(id)?'checked':''}></div><div class="col-12 col-lg-4"><div class="fw-semibold">${x.sag_id?`<button type="button" class="btn btn-link p-0 text-decoration-none fw-semibold case-details" data-sag-id="${Number(x.sag_id)}">#${Number(x.sag_id)} · ${esc(x.case_title)}</button>`:esc(x.case_title||'Ingen sag')}</div><div class="small text-muted">${caseStatus(x.case_status)} ${esc(x.contact_name||'Ingen kontakt')} · ${esc(x.employee_name||'Ukendt medarbejder')}</div></div><div class="col-6 col-lg-2"><div class="small text-muted">Dato og status</div>${esc(x.worked_date||'Ingen dato')} ${status(x.status)}</div><div class="col-6 col-lg-2"><div class="small text-muted">Tid</div><strong>${h(x.approved_hours||x.original_hours)} t</strong><span class="small text-muted"> · ${esc(x.work_type||x.entry_type||'arbejde')}</span></div><div class="col-8 col-lg-2"><div class="small text-muted">Afregning</div>${method(m)} ${x.billable===false?'<span class="small text-muted">ikke fakturerbar</span>':''}</div><div class="col-4 col-lg-2 text-lg-end"><button class="btn btn-sm btn-outline-secondary select-one" data-id="${id}">Gennemgå</button></div></div>${x.description?`<div class="small text-muted mt-2 ps-lg-4">${esc(x.description)}</div>`:'<div class="small text-warning mt-2 ps-lg-4"><i class="bi bi-exclamation-triangle me-1"></i>Mangler beskrivelse</div>'}</div>`}).join('')}</article>`}).join('');document.querySelectorAll('.row-check').forEach(e=>e.addEventListener('change',()=>{const id=Number(e.dataset.id);e.checked?state.selected.add(id):state.selected.delete(id);selectionChanged()}));document.querySelectorAll('.case-details').forEach(e=>e.addEventListener('click',()=>openCaseDetails(Number(e.dataset.sagId))));document.querySelectorAll('.select-one').forEach(e=>e.addEventListener('click',()=>{state.selected=new Set([Number(e.dataset.id)]);selectionChanged();$('settlementMethod').scrollIntoView({behavior:'smooth',block:'center'})}));document.querySelectorAll('.customer-select').forEach(e=>e.addEventListener('click',()=>{const group=e.dataset.ids.split(',').map(Number),allSelected=group.every(id=>state.selected.has(id));group.forEach(id=>allSelected?state.selected.delete(id):state.selected.add(id));selectionChanged()}))}
function url(){const p=new URLSearchParams({limit:'500'});if($('filterCaseState').value)p.set('case_state',$('filterCaseState').value);if($('filterCustomer').value)p.set('customer_id',$('filterCustomer').value);if($('filterStatus').value)p.set('status',$('filterStatus').value);if($('filterBillable').value)p.set('billable',$('filterBillable').value);if($('filterQuery').value.trim())p.set('q',$('filterQuery').value.trim());return `/api/v1/economy/time-queue?${p}`}
async function loadCustomers(){try{const d=await api('/api/v1/economy/time-queue/customers'),v=$('filterCustomer').value;$('filterCustomer').innerHTML=`<option value="">Alle firmaer</option>${(d.items||[]).map(x=>`<option value="${Number(x.customer_id)}">${esc(x.customer_name)} (${Number(x.open_count||0)})</option>`).join('')}`;$('filterCustomer').value=v}catch(_){message('warning','Firmafilteret kunne ikke hentes.') }}
async function loadCards(){const select=$('prepaidCard'),customers=selectedCardCustomerIds(),current=Number(select.value);state.cards=[];select.disabled=true;if(customers.length!==1){select.innerHTML=`<option value="">${customers.length>1?'Vælg tider fra ét firma':'Vælg først tider fra ét firma'}</option>`;renderPrepaidHelp();return}try{const d=await api(`/api/v1/economy/time-queue/prepaid-cards?customer_id=${customers[0]}`);state.cards=(d.items||[]).filter(x=>x.status==='active'&&Number(x.remaining_hours)>0);select.innerHTML=`<option value="">Vælg kundens klippekort</option>${state.cards.map(x=>`<option value="${Number(x.id)}">${esc(x.card_number)} · ${h(x.remaining_hours)} timer tilbage</option>`).join('')}`;select.disabled=!state.cards.length;if(state.cards.some(x=>Number(x.id)===current))select.value=String(current);renderPrepaidHelp()}catch(_){select.innerHTML='<option value="">Klippekort kunne ikke hentes</option>';renderPrepaidHelp()}}
async function load(){if(state.loading)return;state.loading=true;$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter tidsregistreringer…</div>';try{const d=await api(url());state.items=d.items||[];state.selected=new Set(ids().filter(id=>state.items.some(x=>Number(x.id)===id)));state.preview=null;$('previewPanel').classList.add('d-none');render();await loadCards()}catch(e){$('queueContent').innerHTML=`<div class="alert alert-danger">${esc(e.message)}</div>`}finally{state.loading=false}}
async function saveMethod(){if(!ids().length)return message('warning','Vælg mindst én tidsregistrering først.');const m=$('settlementMethod').value;if(!m)return message('warning','Vælg en afregningstype, der skal gemmes.');try{await api('/api/v1/economy/time-queue/bulk-update',{method:'PATCH',body:JSON.stringify({ids:ids(),billing_method:m,billable:['invoice','prepaid'].includes(m)})});message('success','Afregningstypen er gemt.');await load()}catch(e){message('danger',e.message)}}
async function openCaseDetails(sagId){const row=state.items.find(x=>Number(x.sag_id)===sagId)||{},modal=bootstrap.Modal.getOrCreateInstance($('caseDetailModal'));$('caseDetailTitle').textContent=`#${sagId} · ${row.case_title||'Sag'}`;$('openCaseNewTab').href=`/sag/${sagId}/v3`;$('caseDetailBody').innerHTML='<div class="py-4 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter sagsdetaljer…</div>';modal.show();try{const s=await api(`/api/v1/sag/${sagId}`),description=s.beskrivelse||row.description||'Ingen beskrivelse på sagen.';$('caseDetailBody').innerHTML=`<div class="d-flex flex-wrap gap-2 mb-3">${status(s.status)}<span class="badge text-bg-light border">${esc(s.type||'Sag')}</span><span class="badge text-bg-light border">${esc(s.prioritet||'Normal')}</span></div><div class="case-detail-meta mb-3"><div><small>Kunde</small><strong>${esc(row.customer_name||'Ikke tilknyttet')}</strong></div><div><small>Kontakt</small><strong>${esc(row.contact_name||'Ikke tilknyttet')}</strong></div><div><small>Ansvarlig</small><strong>${esc(s.ansvarlig_navn||'Ikke tildelt')}</strong></div><div><small>Senest opdateret</small><strong>${esc(s.updated_at||s.opdateret_at||'Ikke oplyst')}</strong></div></div><h3 class="h6">Beskrivelse</h3><div class="border rounded p-3 bg-light">${esc(description).replace(/\n/g,'<br>')}</div>`}catch(e){$('caseDetailBody').innerHTML=`<div class="alert alert-danger mb-0">${esc(e.message)}</div>`}}
function previewView(d){state.preview=d;$('previewPanel').classList.remove('d-none');const errors=d.errors||[],invoice=d.invoice_groups||[],prepaid=d.prepaid_groups||[],sub=d.subscription_groups||[];$('previewBody').innerHTML=`${errors.length?`<div class="alert alert-danger"><strong>Afregningen kan ikke gennemføres endnu.</strong><ul class="mb-0 mt-2">${errors.map(x=>`<li>Tid #${Number(x.id)}: ${esc(x.message)}</li>`).join('')}</ul></div>`:'<div class="alert alert-success"><i class="bi bi-check-circle me-1"></i>Alle valgte tider er klar til afregning.</div>'}<div class="row g-3">${invoice.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light"><strong>Faktura · ${esc(x.customer_name)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer · ${h(x.hourly_rate)} kr./t</div><div class="fs-5 fw-semibold">${Number(x.amount_ex_vat).toLocaleString('da-DK',{minimumFractionDigits:2})} kr. ekskl. moms</div><div class="small text-muted">Gebyr og evt. fragt vises i ordrekladden.</div></div></div>`).join('')}${prepaid.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#6f42c1"><strong>Klippekort · ${esc(x.card_number||'#'+x.card_id)}</strong><div class="small text-muted">Forbrug: ${h(x.hours)} t · Tilbage før træk: ${h(x.remaining_hours)} t</div></div></div>`).join('')}${sub.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#198754"><strong>Abonnement / fast pris · aftale #${Number(x.agreement_id)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer knyttes til aftalen uden fakturalinje.</div></div></div>`).join('')}</div>`;$('settleBtn').disabled=!d.valid;$('previewPanel').scrollIntoView({behavior:'smooth',block:'center'})}
function previewView(d){
state.preview=d;
$('previewPanel').classList.remove('d-none');
const errors=d.errors||[],invoice=d.invoice_groups||[],prepaid=d.prepaid_groups||[],sub=d.subscription_groups||[],items=d.items||[];
const itemRows=items.map(x=>`<div class="preview-item d-flex flex-wrap justify-content-between gap-2"><div><strong>${esc(x.title||`Tid #${x.id}`)}</strong> <span class="small text-muted">${esc(x.customer_name||'Ukendt kunde')}</span><div class="small mt-1">${caseStatus(x.case_status)} <span class="text-muted">Sagstatus er kun information; løst/lukket sag må gerne afregnes.</span></div></div><div class="text-end"><strong>${h(x.hours)} t</strong><div class="small text-muted">Faktisk ${h(x.actual_hours)} t · ${esc(labels[x.method]||x.method_label||x.method)}</div>${x.message?`<div class="small text-danger">${esc(x.message)}</div>`:''}</div></div>`).join('');
$('previewBody').innerHTML=`<div class="alert alert-info py-2"><strong>Hvad gør forhåndsvisning?</strong> Den kontrollerer valgte registreringer, kunde, klippekort/aftale, timer og beløb. Den gemmer ikke noget og trækker ikke timer. Det sker først efter “Gennemfør afregning”.</div>${errors.length?`<div class="alert alert-danger"><strong>Afregningen kan ikke gennemføres endnu.</strong><ul class="mb-0 mt-2">${errors.map(x=>`<li>Tid #${Number(x.id)}: ${esc(x.message)}</li>`).join('')}</ul></div>`:'<div class="alert alert-success"><i class="bi bi-check-circle me-1"></i>Alle valgte tider kan afregnes. Løst/lukket sagsstatus blokerer ikke.</div>'}<div class="row g-3">${invoice.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light"><strong>Faktura · ${esc(x.customer_name)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer · ${h(x.hourly_rate)} kr./t</div><div class="fs-5 fw-semibold">${Number(x.amount_ex_vat).toLocaleString('da-DK',{minimumFractionDigits:2})} kr. ekskl. moms</div><div class="small text-muted">Gebyr og evt. fragt vises i ordrekladde.</div></div></div>`).join('')}${prepaid.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#6f42c1"><strong>Klippekort · ${esc(x.card_number||'#'+x.card_id)}</strong><div class="small text-muted">Forbrug: ${h(x.hours)} t · Tilbage før træk: ${h(x.remaining_hours)} t · efter: ${h(Number(x.remaining_hours)-Number(x.hours))} t</div><div class="small text-muted">Afrunding: ${Number(x.rounding_minutes)||30} min. pr. registrering</div></div></div>`).join('')}${sub.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#198754"><strong>Abonnement / fast pris · aftale #${Number(x.agreement_id)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer knyttes til aftalen uden særskilt fakturalinje.</div></div></div>`).join('')}</div><h3 class="h6 mt-4">Valgte tidsregistreringer (${items.length})</h3><div class="preview-items">${itemRows||'<div class="text-muted">Ingen registreringer.</div>'}</div>`;
$('settleBtn').disabled=!d.valid;
$('previewPanel').scrollIntoView({behavior:'smooth',block:'center'});
}
async function preview(){if(!ids().length)return message('warning','Vælg mindst én tidsregistrering først.');try{previewView(await api('/api/v1/economy/time-queue/preview-settlement',{method:'POST',body:JSON.stringify(payload())}))}catch(e){message('danger',e.message)}}
async function settle(){if(!state.preview?.valid)return;$('settleBtn').disabled=true;$('settleBtn').innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Afregner…';try{const d=await api('/api/v1/economy/time-queue/settle',{method:'POST',body:JSON.stringify(payload())});message('success',`Afregningen er gennemført.${d.created_drafts?.length?' Ordrekladder er oprettet.':''}`);state.selected.clear();$('previewPanel').classList.add('d-none');if(d.orders_url)$('messageArea').insertAdjacentHTML('beforeend',`<p><a class="btn btn-outline-primary" href="${esc(d.orders_url)}">Åbn ordrekladde</a></p>`);await loadCustomers();await load()}catch(e){message('danger',e.message);if(e.payload?.detail?.preview)previewView(e.payload.detail.preview)}finally{$('settleBtn').disabled=false;$('settleBtn').innerHTML='<i class="bi bi-check2-circle me-1"></i>Gennemfør afregning'}}
function selectionChanged(){state.preview=null;$('previewPanel').classList.add('d-none');render();loadCards()}
$('reloadBtn').onclick=()=>{loadCustomers();load()};$('clearFiltersBtn').onclick=()=>{$('filterCustomer').value='';$('filterStatus').value='';$('filterBillable').value='';$('filterQuery').value='';load()};[$('filterCustomer'),$('filterStatus'),$('filterBillable')].forEach(x=>x.onchange=load);$('filterQuery').onkeydown=e=>e.key==='Enter'&&load();$('prepaidCard').onchange=renderPrepaidHelp;$('setMethodBtn').onclick=saveMethod;$('previewBtn').onclick=preview;$('stickyPreviewBtn').onclick=preview;$('settleBtn').onclick=settle;$('closePreviewBtn').onclick=()=>{$('previewPanel').classList.add('d-none');state.preview=null};$('clearSelectionBtn').onclick=()=>{state.selected.clear();state.preview=null;$('previewPanel').classList.add('d-none');selectionChanged()};loadCustomers();load();
$('reloadBtn').onclick=()=>{loadCustomers();load()};$('clearFiltersBtn').onclick=()=>{$('filterCustomer').value='';$('filterStatus').value='';$('filterBillable').value='';$('filterCaseState').value='';$('filterQuery').value='';load()};[$('filterCustomer'),$('filterStatus'),$('filterCaseState'),$('filterBillable')].forEach(x=>x.onchange=load);$('filterQuery').onkeydown=e=>e.key==='Enter'&&load();$('prepaidCard').onchange=renderPrepaidHelp;$('setMethodBtn').onclick=saveMethod;$('previewBtn').onclick=preview;$('stickyPreviewBtn').onclick=preview;$('settleBtn').onclick=settle;$('closePreviewBtn').onclick=()=>{$('previewPanel').classList.add('d-none');state.preview=null};$('clearSelectionBtn').onclick=()=>{state.selected.clear();state.preview=null;$('previewPanel').classList.add('d-none');selectionChanged()};loadCustomers();load();
})();
</script>
{% endblock %}

View File

@ -2,13 +2,17 @@
from __future__ import annotations
import json
from datetime import date
import re
import hashlib
import math
from html.parser import HTMLParser
from datetime import date, datetime, time as datetime_time, timedelta
from decimal import Decimal
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from psycopg2.extras import Json, RealDictCursor
from app.core.auth_dependencies import require_any_permission, require_permission
@ -85,6 +89,397 @@ class CreatePayload(BaseModel):
idempotency_key: str = Field(min_length=8, max_length=120)
class VtigerCaseImportRequest(BaseModel):
ticket_number: str = Field(min_length=3, max_length=80)
@field_validator("ticket_number")
@classmethod
def validate_ticket_number(cls, value: str) -> str:
normalized = str(value or "").strip().upper()
if not re.fullmatch(r"CC[0-9]{1,12}", normalized):
raise ValueError("Indtast et sagsnummer i formatet CC12345")
return normalized
class HubCaseExportRequest(BaseModel):
sag_id: int = Field(gt=0)
class HubCaseExportConfirmRequest(HubCaseExportRequest):
source_hash: str = Field(min_length=64, max_length=64)
def _vtiger_reference_id(value: Any) -> Optional[str]:
if isinstance(value, dict):
value = value.get("value") or value.get("id")
value = str(value or "").strip()
return value if re.fullmatch(r"[A-Za-z0-9_-]+x[0-9]+", value) else None
def _vtiger_case_number(raw: Dict[str, Any]) -> str:
for key in ("ticket_no", "ticketnumber", "ticket_number", "case_no", "casenumber"):
value = str(raw.get(key) or "").strip()
if value:
return value.upper()
return ""
def _case_status_from_vtiger(value: Any) -> str:
status = _vtiger_text(value).lower()
if status in {"closed", "lukket", "afsluttet", "done", "cancelled", "canceled"}:
return "Lukket"
if status in {"resolved", "løst", "lost"}:
return "Løst"
if status in {"assigned", "tildelt"}:
return "Tildelt"
if status in {"in progress", "in_progress", "igangværende", "open", "åben", "reopened"}:
return "Igangværende" if status not in {"open", "åben"} else "Ny"
if status in {"waiting", "waiting_customer", "waiting_internal", "on_hold", "afventer"}:
return "Afventer"
return "Ny"
def _case_priority_from_vtiger(value: Any) -> str:
priority = _vtiger_text(value).lower()
if priority in {"urgent", "critical", "akut"}:
return "urgent"
if priority in {"high", "høj"}:
return "high"
if priority in {"low", "lav"}:
return "low"
return "normal"
def _vtiger_text(value: Any) -> str:
if isinstance(value, dict):
value = value.get("label") or value.get("value") or ""
return str(value or "").strip()
class _VTigerPlainTextParser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.parts = []
def handle_starttag(self, tag, attrs):
if tag.lower() in {"br", "p", "div", "tr", "h1", "h2", "h3", "h4", "h5", "h6"}:
self.parts.append("\n")
def handle_endtag(self, tag):
if tag.lower() in {"p", "div", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6"}:
self.parts.append("\n")
def handle_data(self, data):
self.parts.append(data)
def _vtiger_plain_text(value: Any) -> str:
raw = _vtiger_text(value)
if not raw:
return ""
parser = _VTigerPlainTextParser()
parser.feed(raw)
text = re.sub(r"\n[ \t]*\n+", "\n\n", "".join(parser.parts))
return text.strip()
def _vtiger_timestamp(value: Any) -> Optional[datetime]:
if not value:
return None
text = str(value).strip().replace("T", " ")
try:
return datetime.fromisoformat(text[:19])
except ValueError:
return None
def _timelog_source_id(row: Dict[str, Any]) -> str:
return str(row.get("id") or "").strip()
def _timelog_description(row: Dict[str, Any], *, legacy: bool = False) -> str:
# Older imports preferred the generic record name (often "migration").
keys = ("name", "cf_timelog_description", "description") if legacy else (
"description", "cf_timelog_description", "name"
)
for key in keys:
value = _vtiger_plain_text(row.get(key))
if value:
return value
return "Importerede vTiger-timer"
async def _resolve_vtiger_assignee_names(timelogs: List[Dict[str, Any]]) -> Dict[str, str]:
"""Resolve time-entry owners in two batch queries instead of one call per row."""
refs = sorted({
ref for row in timelogs
if (ref := _vtiger_reference_id(row.get("assigned_user_id")))
})
if not refs:
return {}
id_list = "', '".join(refs)
vtiger = get_vtiger_service()
names: Dict[str, str] = {}
users = await vtiger.query(
"SELECT id, first_name, last_name, user_name FROM Users "
f"WHERE id IN ('{id_list}');"
)
for row in users or []:
ref = _vtiger_reference_id(row.get("id"))
if not ref:
continue
full_name = " ".join(
part for part in (_vtiger_text(row.get("first_name")), _vtiger_text(row.get("last_name"))) if part
).strip()
names[ref] = full_name or _vtiger_text(row.get("user_name")) or ref
groups = await vtiger.query(
"SELECT id, groupname FROM Groups "
f"WHERE id IN ('{id_list}');"
)
for row in groups or []:
ref = _vtiger_reference_id(row.get("id"))
if ref and ref not in names:
names[ref] = _vtiger_text(row.get("groupname")) or ref
return names
async def _load_vtiger_case_bundle(ticket_number: str) -> Dict[str, Any]:
normalized = str(ticket_number or "").strip().upper()
if not re.fullmatch(r"CC[0-9]{1,12}", normalized):
raise HTTPException(status_code=400, detail="Indtast et sagsnummer i formatet CC12345")
vtiger = get_vtiger_service()
case = None
for field in ("ticket_no", "ticketnumber", "ticket_number", "case_no"):
safe_value = normalized.replace("'", "''")
records = await vtiger.query(f"SELECT * FROM Cases WHERE {field}='{safe_value}' LIMIT 2;")
if records:
if len(records) > 1:
raise HTTPException(status_code=409, detail=f"Flere vTiger-sager matcher {normalized}; overførslen er stoppet")
case = records[0]
break
if not case:
raise HTTPException(status_code=404, detail=f"Sagen {normalized} blev ikke fundet i vTiger")
vtiger_case_id = _vtiger_reference_id(case.get("id"))
if not vtiger_case_id:
raise HTTPException(status_code=502, detail="vTiger-sagen mangler et gyldigt post-id")
actual_number = _vtiger_case_number(case) or normalized
if actual_number != normalized:
raise HTTPException(status_code=409, detail=f"vTiger returnerede {actual_number} i stedet for {normalized}; overførslen er stoppet")
account_id = _vtiger_reference_id(case.get("parent_id") or case.get("account_id"))
if not account_id:
raise HTTPException(status_code=409, detail="Sagen har ingen entydig vTiger-kundetilknytning")
customer = execute_query_single(
"SELECT id, name, vtiger_id FROM customers WHERE vtiger_id = %s AND deleted_at IS NULL",
(account_id,),
)
if not customer:
account_rows = await vtiger.query(f"SELECT * FROM Accounts WHERE id='{account_id}' LIMIT 1;")
account = account_rows[0] if account_rows else {}
account_name = str(account.get("accountname") or "").strip()
if account_name:
matches = execute_query(
"SELECT id, name, vtiger_id FROM customers WHERE LOWER(TRIM(name)) = LOWER(TRIM(%s)) AND deleted_at IS NULL ORDER BY id LIMIT 2",
(account_name,),
) or []
if len(matches) == 1:
customer = matches[0]
if not customer:
raise HTTPException(
status_code=409,
detail=f"Kunden {account_id} er ikke entydigt tilknyttet en Hub-kunde. Link kunden først.",
)
timelogs = await vtiger.query(
f"SELECT * FROM Timelog WHERE relatedto='{vtiger_case_id}' ORDER BY startedon LIMIT 1001;"
)
comments = await vtiger.query(
f"SELECT * FROM ModComments WHERE related_to='{vtiger_case_id}' ORDER BY createdtime LIMIT 1000;"
)
# vTiger installations can ignore filters on reference fields; enforce the
# relationship locally before any source records are shown or imported.
timelogs = [row for row in (timelogs or []) if _vtiger_reference_id(row.get("relatedto")) == vtiger_case_id]
comments = [row for row in (comments or []) if _vtiger_reference_id(row.get("related_to")) == vtiger_case_id]
if len(timelogs) > 1000:
raise HTTPException(status_code=409, detail="Sagen har mere end 1.000 tidsregistreringer. Overførslen er stoppet for at undgå en ufuldstændig kopi.")
assignee_names = await _resolve_vtiger_assignee_names(timelogs)
contact_id = _vtiger_reference_id(case.get("contact_id") or case.get("contactid"))
contact = execute_query_single(
"SELECT id, first_name, last_name FROM contacts WHERE vtiger_id = %s",
(contact_id,),
) if contact_id else None
imported = execute_query_single(
"SELECT hub_sag_id, vtiger_marked_at FROM vtiger_case_hub_imports WHERE vtiger_case_id = %s",
(vtiger_case_id,),
)
return {
"case": case,
"ticket_number": actual_number,
"vtiger_case_id": vtiger_case_id,
"account_id": account_id,
"customer": customer,
"contact": contact,
"timelogs": timelogs or [],
"timelog_assignee_names": assignee_names,
"comments": comments or [],
"existing_import": imported,
}
def _import_vtiger_case_bundle(bundle: Dict[str, Any], current_user: dict) -> Dict[str, Any]:
"""Create a native Hub case and link source time entries atomically."""
conn = get_db_connection()
imported_now = False
vtiger_case_id = bundle["vtiger_case_id"]
case = bundle["case"]
customer = bundle["customer"]
current_user_id = user_id(current_user)
imported_time_entries = 0
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"SELECT hub_sag_id, vtiger_marked_at FROM vtiger_case_hub_imports WHERE vtiger_case_id=%s FOR UPDATE",
(vtiger_case_id,),
)
mapped = cursor.fetchone()
if mapped:
sag_id = mapped["hub_sag_id"]
# Repair legacy imports without changing approval or billing state.
assignee_names = bundle.get("timelog_assignee_names", {})
for timelog in bundle.get("timelogs", []):
source_id = _timelog_source_id(timelog)
assignee_ref = _vtiger_reference_id(timelog.get("assigned_user_id"))
old_user_name = _vtiger_text(timelog.get("assigned_user_id"))
resolved_user_name = assignee_names.get(assignee_ref)
if not source_id:
continue
if assignee_ref and resolved_user_name and resolved_user_name != old_user_name:
cursor.execute(
"""UPDATE tmodule_times SET user_name=%s
WHERE vtiger_id=%s AND sag_id=%s AND user_name=%s""",
(resolved_user_name, source_id, sag_id, old_user_name),
)
old_description = _timelog_description(timelog, legacy=True)
new_description = _timelog_description(timelog)
if old_description != new_description:
cursor.execute(
"""UPDATE tmodule_times SET description=%s
WHERE vtiger_id=%s AND sag_id=%s AND description=%s""",
(new_description, source_id, sag_id, old_description),
)
else:
cursor.execute(
"""INSERT INTO sag_sager
(titel, beskrivelse, template_key, type, status, priority, customer_id, created_by_user_id)
VALUES (%s,%s,'ticket','ticket',%s,%s,%s,%s) RETURNING id""",
(
_vtiger_text(case.get("ticket_title") or case.get("subject")) or bundle["ticket_number"],
_vtiger_text(case.get("description")),
_case_status_from_vtiger(case.get("status")),
_case_priority_from_vtiger(case.get("priority")),
customer["id"], current_user_id,
),
)
sag_id = cursor.fetchone()["id"]
contact = bundle.get("contact")
if contact:
cursor.execute(
"INSERT INTO sag_kontakter (sag_id, contact_id, role) VALUES (%s,%s,'Primær kontakt')",
(sag_id, contact["id"]),
)
cursor.execute(
"""INSERT INTO tmodule_customers (vtiger_id, name, hub_customer_id, vtiger_data)
VALUES (%s,%s,%s,%s) ON CONFLICT (vtiger_id) DO UPDATE
SET hub_customer_id=COALESCE(tmodule_customers.hub_customer_id, EXCLUDED.hub_customer_id)
RETURNING id""",
(bundle["account_id"], customer["name"], customer["id"], Json({"id": bundle["account_id"]})),
)
tcustomer_id = cursor.fetchone()["id"]
cursor.execute(
"""INSERT INTO tmodule_cases
(vtiger_id, customer_id, title, description, status, priority, module_type, vtiger_data, sync_hash)
VALUES (%s,%s,%s,%s,%s,%s,'Cases',%s,%s)
ON CONFLICT (vtiger_id) DO UPDATE SET
customer_id=EXCLUDED.customer_id, title=EXCLUDED.title,
description=EXCLUDED.description, status=EXCLUDED.status,
priority=EXCLUDED.priority, vtiger_data=EXCLUDED.vtiger_data,
sync_hash=EXCLUDED.sync_hash, last_synced_at=CURRENT_TIMESTAMP
RETURNING id""",
(
vtiger_case_id, tcustomer_id,
_vtiger_text(case.get("ticket_title") or case.get("subject")) or bundle["ticket_number"],
_vtiger_text(case.get("description")), _vtiger_text(case.get("status")),
_vtiger_text(case.get("priority")), Json(case),
hashlib.sha256(json.dumps(case, sort_keys=True, default=str).encode()).hexdigest(),
),
)
tcase_id = cursor.fetchone()["id"]
for timelog in bundle.get("timelogs", []):
source_id = _timelog_source_id(timelog)
seconds = _amount(timelog.get("duration"))
if not source_id or seconds <= 0:
continue
hours = (seconds / Decimal("3600")).quantize(Decimal("0.01"))
if hours <= 0:
continue
billable = str(timelog.get("isbillable") or "0") == "1"
already_billed = any(str(timelog.get(key) or "0") not in {"", "0"} for key in (
"cf_timelog_invoiced", "billed_via_thehub_id", "cf_timelog_billedviathehubid",
"cf_timelog_rounduptimespent",
))
start = _vtiger_timestamp(timelog.get("startedon") or timelog.get("createdtime"))
data_hash = hashlib.sha256(json.dumps(timelog, sort_keys=True, default=str).encode()).hexdigest()
cursor.execute(
"""INSERT INTO tmodule_times
(vtiger_id, case_id, customer_id, sag_id, description, original_hours,
worked_date, user_name, billable, vtiger_data, sync_hash, status,
approved_hours, billing_method, faktisk_tid_min, fakturerbar_tid_min,
start_tid, entry_status, kilde, entry_type, work_type)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'api','ukendt','support')
ON CONFLICT (vtiger_id) DO UPDATE SET sag_id=EXCLUDED.sag_id
WHERE tmodule_times.case_id=EXCLUDED.case_id
AND (tmodule_times.sag_id IS NULL OR tmodule_times.sag_id=EXCLUDED.sag_id)
RETURNING id""",
(
source_id, tcase_id, tcustomer_id, sag_id,
_timelog_description(timelog),
hours, start.date() if start else _safe_date(timelog.get("startedon")),
bundle.get("timelog_assignee_names", {}).get(
_vtiger_reference_id(timelog.get("assigned_user_id")),
_vtiger_text(timelog.get("assigned_user_id")),
), billable, Json(timelog), data_hash,
"billed" if already_billed else "pending", hours if already_billed else None,
"invoice" if billable else "internal", int((seconds / Decimal("60")).to_integral_value()),
int((seconds / Decimal("60")).to_integral_value()) if billable else 0,
start, "godkendt" if already_billed else "afventer",
),
)
if cursor.fetchone():
imported_time_entries += 1
cursor.execute(
"""INSERT INTO vtiger_case_hub_imports
(vtiger_case_id,ticket_number,hub_sag_id,hub_customer_id,imported_by_user_id,source_snapshot)
VALUES (%s,%s,%s,%s,%s,%s)""",
(vtiger_case_id, bundle["ticket_number"], sag_id, customer["id"], current_user_id,
Json({"case": case, "comments_not_imported": len(bundle.get("comments", []))})),
)
imported_now = True
conn.commit()
return {"hub_sag_id": sag_id, "imported": imported_now,
"time_entries": imported_time_entries,
"comments_available_not_imported": len(bundle.get("comments", [])) if imported_now else 0}
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
def _safe_date(value: Any) -> Optional[date]:
if not value:
return None
@ -804,6 +1199,434 @@ async def customer_overview(
}
@router.post("/vtiger-cases/preview")
async def preview_vtiger_case_import(
payload: VtigerCaseImportRequest,
current_user: dict = Depends(require_permission("migration_center.import")),
):
bundle = await _load_vtiger_case_bundle(payload.ticket_number)
raw = bundle["case"]
already_imported = bundle.get("existing_import")
eligible_timelogs = [row for row in bundle.get("timelogs", []) if _amount(row.get("duration")) > 0]
return {
"ticket_number": bundle["ticket_number"],
"vtiger_case_id": bundle["vtiger_case_id"],
"title": _vtiger_text(raw.get("ticket_title") or raw.get("subject")) or bundle["ticket_number"],
"description": _vtiger_plain_text(raw.get("description")),
"status": _case_status_from_vtiger(raw.get("status")),
"priority": _case_priority_from_vtiger(raw.get("priority")),
"customer": bundle["customer"],
"contact": bundle.get("contact"),
"time_entries": len(eligible_timelogs),
"time_hours": str(sum((_amount(row.get("duration")) for row in eligible_timelogs), Decimal("0")) / Decimal("3600")),
"comments_available_not_imported": len(bundle.get("comments", [])),
"already_imported": bool(already_imported),
"hub_sag_id": already_imported.get("hub_sag_id") if already_imported else None,
"vtiger_marked": bool(already_imported and already_imported.get("vtiger_marked_at")),
}
@router.post("/vtiger-cases/import")
async def import_vtiger_case(
payload: VtigerCaseImportRequest,
request: Request,
current_user: dict = Depends(require_permission("migration_center.import")),
):
bundle = await _load_vtiger_case_bundle(payload.ticket_number)
result = _import_vtiger_case_bundle(bundle, current_user)
marked = bool(bundle.get("existing_import") and bundle["existing_import"].get("vtiger_marked_at"))
mark_error = None
if not marked:
configured_field = getattr(settings, "VTIGER_CASE_HUBBED_FIELD", "cf_cases_hubbet") or "cf_cases_hubbet"
if not re.fullmatch(r"cf_[A-Za-z0-9_]+", configured_field):
mark_error = "VTIGER_CASE_HUBBED_FIELD er ikke konfigureret med et gyldigt VTiger-feltnavn"
else:
try:
await get_vtiger_service().update_record_fields("Cases", bundle["vtiger_case_id"], {configured_field: "1"})
marked = True
execute_query(
"UPDATE vtiger_case_hub_imports SET vtiger_marked_at=CURRENT_TIMESTAMP WHERE vtiger_case_id=%s",
(bundle["vtiger_case_id"],), fetch=False,
)
except Exception as exc:
mark_error = f"Sagen blev overført, men vTiger kunne ikke markeres Hubbet: {str(exc)[:300]}"
audit(
request=request, current_user=current_user, action="vtiger_case_imported",
entity_type="case", entity_id=result["hub_sag_id"],
new_value={"vtiger_case_id": bundle["vtiger_case_id"], "ticket_number": bundle["ticket_number"],
"imported": result["imported"], "vtiger_marked": marked},
)
return {"success": True, **result, "vtiger_marked": marked, "warning": mark_error}
def _hub_status_to_vtiger(value: Any, allowed: set[str]) -> str:
key = _vtiger_text(value).strip().lower()
candidates = {
"ny": "New", "tildelt": "Assigned", "igangværende": "In Progress",
"afventer": "Wait for customer", "løst": "Resolved", "lukket": "Closed",
"kunde svaret": "Open", "åben": "Open",
}
candidate = candidates.get(key, "New")
return candidate if candidate in allowed else ("Open" if "Open" in allowed else next(iter(allowed), "New"))
def _hub_priority_to_vtiger(value: Any, allowed: set[str]) -> str:
key = _vtiger_text(value).strip().lower()
candidates = {"urgent": "Urgent", "high": "High", "normal": "Medium", "low": "Low"}
candidate = candidates.get(key, "Medium")
return candidate if candidate in allowed else ("Medium" if "Medium" in allowed else next(iter(allowed), candidate))
def _time_export_minutes(row: Dict[str, Any]) -> int:
actual = row.get("faktisk_tid_min")
if actual not in (None, "") and int(actual or 0) > 0:
return int(actual)
hours = row.get("original_hours") or row.get("approved_hours") or 0
return max(0, int((_amount(hours) * Decimal("60")).to_integral_value()))
def _time_export_billable(row: Dict[str, Any]) -> bool:
method = _vtiger_text(row.get("billing_method")).lower()
return bool(row.get("billable")) and method in {"", "invoice", "faktura"}
def _normalized_person(value: Any) -> str:
return " ".join(_vtiger_text(value).casefold().split())
async def _load_hub_case_export_bundle(sag_id: int) -> Dict[str, Any]:
case = execute_query_single(
"""SELECT s.id, s.titel, s.beskrivelse, s.status, s.priority::text AS priority,
s.customer_id, c.name AS customer_name, c.vtiger_id AS account_vtiger_id,
s.ansvarlig_bruger_id, owner.full_name AS owner_name, owner.email AS owner_email
FROM sag_sager s
JOIN customers c ON c.id=s.customer_id
LEFT JOIN users owner ON owner.user_id=s.ansvarlig_bruger_id
WHERE s.id=%s AND s.deleted_at IS NULL""",
(sag_id,),
)
if not case:
raise HTTPException(status_code=404, detail=f"Hub-sag #{sag_id} blev ikke fundet")
contact = execute_query_single(
"""SELECT c.id, c.first_name, c.last_name, c.email, c.vtiger_id
FROM sag_kontakter sc JOIN contacts c ON c.id=sc.contact_id
WHERE sc.sag_id=%s AND sc.deleted_at IS NULL AND c.vtiger_id IS NOT NULL
ORDER BY sc.is_primary DESC, sc.id LIMIT 1""",
(sag_id,),
)
if not contact:
contact = execute_query_single(
"""SELECT c.id, c.first_name, c.last_name, c.email, c.vtiger_id
FROM contact_companies cc JOIN contacts c ON c.id=cc.contact_id
WHERE cc.customer_id=%s AND cc.is_primary IS TRUE AND c.vtiger_id IS NOT NULL
ORDER BY cc.id LIMIT 1""",
(case["customer_id"],),
)
times = execute_query(
"""SELECT t.id, t.description, t.worked_date, t.start_tid, t.slut_tid,
t.original_hours, t.approved_hours, t.faktisk_tid_min, t.fakturerbar_tid_min,
t.billable, t.billing_method, t.status, t.entry_status, t.vtiger_id,
t.user_name, t.medarbejder_id, u.full_name AS technician_name,
u.email AS technician_email
FROM tmodule_times t
LEFT JOIN users u ON u.user_id=t.medarbejder_id
WHERE t.sag_id=%s AND t.vtiger_id IS NULL
AND COALESCE(t.entry_status, 'godkendt')='godkendt'
AND COALESCE(t.status, '') NOT IN ('billed', 'rejected')
AND t.billed_via_thehub_id IS NULL AND t.economy_settled_at IS NULL
AND t.economy_order_draft_id IS NULL
AND NOT EXISTS (SELECT 1 FROM hub_time_vtiger_exports x WHERE x.hub_time_id=t.id)
ORDER BY COALESCE(t.start_tid, t.worked_date::timestamp, t.created_at), t.id""",
(sag_id,),
) or []
all_times = execute_query_single(
"""SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE COALESCE(entry_status, 'godkendt') <> 'godkendt') AS pending
FROM tmodule_times WHERE sag_id=%s""",
(sag_id,),
) or {"total": 0, "pending": 0}
export_mapping = execute_query_single(
"SELECT vtiger_case_id, ticket_number, status FROM hub_case_vtiger_exports WHERE hub_sag_id=%s",
(sag_id,),
)
imported_mapping = execute_query_single(
"SELECT vtiger_case_id, ticket_number FROM vtiger_case_hub_imports WHERE hub_sag_id=%s",
(sag_id,),
)
return {
"case": case, "contact": contact, "times": times,
"pending_time_count": int(all_times.get("pending") or 0),
"export_mapping": export_mapping, "imported_mapping": imported_mapping,
}
async def _vtiger_user_records(vtiger) -> list[Dict[str, Any]]:
records: list[Dict[str, Any]] = []
for offset in range(0, 500, 100):
batch = await vtiger.query(
"SELECT id, first_name, last_name, user_name, email1 FROM Users "
f"LIMIT {offset}, 100;"
)
records.extend(batch or [])
if len(batch or []) < 100:
break
return records
def _match_vtiger_user(row: Dict[str, Any], users: list[Dict[str, Any]]) -> Optional[str]:
email = _normalized_person(row.get("technician_email"))
name = _normalized_person(row.get("technician_name") or row.get("user_name"))
matches = []
for user in users:
user_id = _vtiger_reference_id(user.get("id"))
user_name = " ".join(
part for part in (_vtiger_text(user.get("first_name")), _vtiger_text(user.get("last_name"))) if part
).strip()
if not user_id:
continue
if email and email in {_normalized_person(user.get("email1")), _normalized_person(user.get("user_name"))}:
return user_id
if name and name in {_normalized_person(user_name), _normalized_person(user.get("user_name"))}:
matches.append(user_id)
return matches[0] if len(set(matches)) == 1 else None
def _export_time_payload(row: Dict[str, Any], sag_id: int, vtiger_case_id: str, vtiger_user_id: str) -> Dict[str, Any]:
minutes = _time_export_minutes(row)
start = row.get("start_tid")
if not isinstance(start, datetime):
work_date = row.get("worked_date")
if isinstance(work_date, datetime):
work_date = work_date.date()
if not isinstance(work_date, date):
raise ValueError(f"Tidsregistrering #{row['id']} mangler en gyldig dato")
start = datetime.combine(work_date, datetime_time(9, 0))
duration_seconds = minutes * 60
description = _vtiger_text(row.get("description"))
name = description[:250] or f"Hub sag #{sag_id} · {start:%Y-%m-%d}"
method = _vtiger_text(row.get("billing_method")).lower()
return {
"name": name,
"description": description,
"relatedto": vtiger_case_id,
"startedon": start.strftime("%Y-%m-%d %H:%M:%S"),
"endedon": (start + timedelta(seconds=duration_seconds)).strftime("%Y-%m-%d %H:%M:%S"),
"duration": str(duration_seconds),
"assigned_user_id": vtiger_user_id,
"isbillable": "1" if _time_export_billable(row) else "0",
"timelog_status": "Completed",
"cf_timelog_invoiced": "0",
}
async def _prepare_hub_case_export(sag_id: int) -> Dict[str, Any]:
bundle = await _load_hub_case_export_bundle(sag_id)
case = bundle["case"]
blockers = []
if bundle.get("imported_mapping"):
blockers.append("Sagen stammer allerede fra vTiger og kan ikke oprettes dér igen.")
if not case.get("account_vtiger_id"):
blockers.append("Kunden mangler en VTiger-kontakt/linkning.")
if not bundle.get("contact") or not bundle["contact"].get("vtiger_id"):
blockers.append("Sagen skal have en primær kontakt, der er linket til VTiger.")
vtiger = get_vtiger_service()
case_meta = await vtiger.describe_module("Cases")
timelog_meta = await vtiger.describe_module("Timelog")
fields = {f.get("name"): f for f in case_meta.get("fields", [])}
time_fields = {f.get("name"): f for f in timelog_meta.get("fields", [])}
required_case = [name for name, field in fields.items() if field.get("mandatory") and field.get("editable")]
required_time = [name for name, field in time_fields.items() if field.get("mandatory") and field.get("editable")]
if set(required_case) - {"title", "description", "casestatus", "casepriority", "parent_id", "contact_id", "cf_cases_hubbet"}:
blockers.append("VTiger har nye obligatoriske sagsfelter, som endnu ikke er mappet.")
if set(required_time) - {"name", "description", "relatedto", "startedon", "endedon", "assigned_user_id", "duration", "isbillable", "timelog_status", "cf_timelog_invoiced"}:
blockers.append("VTiger har nye obligatoriske tidsfelter, som endnu ikke er mappet.")
if any(name not in fields for name in ("title", "description", "casestatus", "casepriority", "parent_id", "contact_id")):
blockers.append("VTigers sagsfelter svarer ikke til den understøttede overførsel.")
if any(name not in time_fields for name in ("name", "description", "relatedto", "startedon", "endedon", "assigned_user_id", "duration", "isbillable", "timelog_status")):
blockers.append("VTigers tidsregistreringsfelter svarer ikke til den understøttede overførsel.")
allowed_statuses = {str(p.get("value")) for p in fields.get("casestatus", {}).get("type", {}).get("picklistValues", []) if p.get("value")}
allowed_priorities = {str(p.get("value")) for p in fields.get("casepriority", {}).get("type", {}).get("picklistValues", []) if p.get("value")}
users = await _vtiger_user_records(vtiger) if bundle["times"] else []
enriched_times = []
for row in bundle["times"]:
technician_name = _vtiger_text(row.get("technician_name") or row.get("user_name"))
vtiger_user_id = _match_vtiger_user(row, users)
if not vtiger_user_id:
blockers.append(f"Teknikeren på tidsregistrering #{row['id']} matcher ikke en VTiger-bruger.")
enriched_times.append({
**row,
"technician_name": technician_name or "Ukendt tekniker",
"vtiger_user_id": vtiger_user_id,
"minutes": _time_export_minutes(row),
"export_billable": _time_export_billable(row),
})
case_hash_data = {
"case": {key: str(case.get(key) or "") for key in ("id", "titel", "beskrivelse", "status", "priority", "account_vtiger_id")},
"contact": {key: str(bundle["contact"].get(key) or "") for key in ("vtiger_id", "email")} if bundle.get("contact") else {},
"times": [
{key: str(row.get(key) or "") for key in ("id", "description", "worked_date", "start_tid", "original_hours", "faktisk_tid_min", "fakturerbar_tid_min", "billable", "billing_method", "technician_email", "technician_name")}
for row in enriched_times
],
}
source_hash = hashlib.sha256(json.dumps(case_hash_data, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
return {
**bundle,
"times": enriched_times,
"blockers": sorted(set(blockers)),
"source_hash": source_hash,
"case_meta": case_meta,
"allowed_statuses": allowed_statuses,
"allowed_priorities": allowed_priorities,
"required_case_fields": required_case,
"required_time_fields": required_time,
"status_value": _hub_status_to_vtiger(case.get("status"), allowed_statuses),
"priority_value": _hub_priority_to_vtiger(case.get("priority"), allowed_priorities),
}
@router.post("/hub-cases/preview")
async def preview_hub_case_export(
payload: HubCaseExportRequest,
current_user: dict = Depends(require_permission("migration_center.import")),
):
data = await _prepare_hub_case_export(payload.sag_id)
case = data["case"]
mapping = data.get("export_mapping")
return {
"sag_id": case["id"], "title": case["titel"],
"description": _vtiger_plain_text(case.get("beskrivelse")),
"customer_name": case.get("customer_name"),
"contact_name": " ".join(filter(None, [data.get("contact", {}).get("first_name"), data.get("contact", {}).get("last_name")])) if data.get("contact") else None,
"status": case.get("status"), "vtiger_status": data["status_value"],
"priority": case.get("priority"), "vtiger_priority": data["priority_value"],
"time_entries": len(data["times"]),
"time_hours": str(sum((Decimal(row["minutes"]) / Decimal("60") for row in data["times"]), Decimal("0"))),
"time_rows": [{"id": row["id"], "date": str(row.get("worked_date") or ""), "description": _vtiger_plain_text(row.get("description")), "technician": row["technician_name"], "hours": str(Decimal(row["minutes"]) / Decimal("60")), "billable": row["export_billable"]} for row in data["times"]],
"pending_time_count": data["pending_time_count"],
"already_exported": bool(mapping),
"vtiger_case_id": mapping.get("vtiger_case_id") if mapping else None,
"ticket_number": mapping.get("ticket_number") if mapping else None,
"blockers": data["blockers"], "source_hash": data["source_hash"],
}
@router.post("/hub-cases/export")
async def export_hub_case_to_vtiger(
payload: HubCaseExportConfirmRequest,
request: Request,
current_user: dict = Depends(require_permission("migration_center.import")),
):
data = await _prepare_hub_case_export(payload.sag_id)
if data["source_hash"] != payload.source_hash:
raise HTTPException(status_code=409, detail="Sagen eller tidsregistreringer er ændret siden forhåndsvisningen. Hent en ny forhåndsvisning.")
if data["blockers"]:
raise HTTPException(status_code=409, detail={"message": "Sagen kan ikke overføres endnu.", "blockers": data["blockers"]})
conn = get_db_connection()
vtiger = get_vtiger_service()
created_case = False
created_times = 0
warnings = []
mapping = None
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("SELECT pg_advisory_lock(1046, %s)", (payload.sag_id,))
cursor.execute("SELECT vtiger_case_id, ticket_number FROM hub_case_vtiger_exports WHERE hub_sag_id=%s FOR UPDATE", (payload.sag_id,))
mapping = cursor.fetchone()
if not mapping:
cursor.execute("SELECT 1 FROM vtiger_case_hub_imports WHERE hub_sag_id=%s", (payload.sag_id,))
if cursor.fetchone():
raise HTTPException(status_code=409, detail="Sagen findes allerede i VTiger fra en tidligere VTiger-overførsel.")
case = data["case"]
case_payload = {
"title": _vtiger_text(case.get("titel"))[:250],
"description": _vtiger_plain_text(case.get("beskrivelse")) or f"Hub sag #{payload.sag_id}",
"casestatus": data["status_value"],
"casepriority": data["priority_value"],
"parent_id": _vtiger_reference_id(case.get("account_vtiger_id")),
"contact_id": _vtiger_reference_id(data["contact"].get("vtiger_id")),
"cf_cases_hubbet": "1",
}
result = await vtiger.create_record("Cases", case_payload)
vtiger_case_id = _vtiger_reference_id(result.get("id"))
if not vtiger_case_id:
raise RuntimeError("VTiger oprettede sagen, men returnerede ikke et gyldigt sags-id.")
ticket_number = _vtiger_text(result.get("case_no") or result.get("ticket_no")) or None
cursor.execute(
"""INSERT INTO hub_case_vtiger_exports (hub_sag_id, vtiger_case_id, ticket_number, status)
VALUES (%s,%s,%s,'partial')""",
(payload.sag_id, vtiger_case_id, ticket_number),
)
conn.commit()
mapping = {"vtiger_case_id": vtiger_case_id, "ticket_number": ticket_number}
created_case = True
vtiger_case_id = mapping["vtiger_case_id"]
conn.commit()
for row in data["times"]:
cursor.execute("SELECT vtiger_timelog_id FROM hub_time_vtiger_exports WHERE hub_time_id=%s", (row["id"],))
if cursor.fetchone():
continue
try:
time_payload = _export_time_payload(row, payload.sag_id, vtiger_case_id, row["vtiger_user_id"])
time_result = await vtiger.create_record("Timelog", time_payload)
vtiger_timelog_id = _vtiger_reference_id(time_result.get("id"))
if not vtiger_timelog_id:
raise RuntimeError("VTiger oprettede tiden, men returnerede ikke et gyldigt tids-id.")
cursor.execute(
"""INSERT INTO hub_time_vtiger_exports (hub_time_id, hub_sag_id, vtiger_timelog_id)
VALUES (%s,%s,%s)""",
(row["id"], payload.sag_id, vtiger_timelog_id),
)
conn.commit()
created_times += 1
except Exception as exc:
warnings.append(f"Tidsregistrering #{row['id']}: {str(exc)[:250]}")
break
cursor.execute(
"""UPDATE hub_case_vtiger_exports SET status=%s, ticket_number=COALESCE(ticket_number,%s),
last_error=%s, updated_at=CURRENT_TIMESTAMP WHERE hub_sag_id=%s""",
("complete" if not warnings else "partial", mapping.get("ticket_number"), "; ".join(warnings) or None, payload.sag_id),
)
conn.commit()
except HTTPException:
conn.rollback()
raise
except Exception as exc:
conn.rollback()
warnings.append(str(exc)[:300])
execute_query(
"UPDATE hub_case_vtiger_exports SET status='partial', last_error=%s, updated_at=CURRENT_TIMESTAMP WHERE hub_sag_id=%s",
("; ".join(warnings), payload.sag_id), fetch=False,
)
if not mapping:
raise HTTPException(status_code=502, detail=f"VTiger-overførslen fejlede: {str(exc)[:300]}") from exc
finally:
try:
with conn.cursor() as cursor:
cursor.execute("SELECT pg_advisory_unlock(1046, %s)", (payload.sag_id,))
conn.commit()
except Exception:
conn.rollback()
release_db_connection(conn)
result = {
"success": True, "sag_id": payload.sag_id,
"vtiger_case_id": vtiger_case_id,
"ticket_number": mapping.get("ticket_number"),
"created_case": created_case,
"created_time_entries": created_times,
"complete": not warnings,
"warning": "; ".join(warnings) or None,
}
audit(
request=request, current_user=current_user, action="hub_case_exported_to_vtiger",
entity_type="case", entity_id=payload.sag_id,
new_value={key: value for key, value in result.items() if key not in {"warning"}},
)
return result
@router.post("/items/{item_id}/rematch")
async def rematch(
item_id: int, request: Request,

View File

@ -64,6 +64,8 @@
<li class="nav-item"><button class="nav-link" data-view="deviations">Afvigelser</button></li>
<li class="nav-item"><button class="nav-link" data-view="locked">Låste</button></li>
<li class="nav-item"><button class="nav-link" data-view="audit">Log / historik</button></li>
<li class="nav-item"><button class="nav-link" data-view="case_import"><i class="bi bi-arrow-left-right me-1"></i>Overfør VTiger-sag</button></li>
<li class="nav-item"><button class="nav-link" data-view="hub_export"><i class="bi bi-box-arrow-up-right me-1"></i>Overfør Hub-sag til VTiger</button></li>
</ul>
<section id="queuePanel" class="card mc-stat mc-queue mb-4 d-none">
@ -104,6 +106,81 @@
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between gap-3 align-items-end"><div><h2 class="h5 mb-1" id="historyTitle">Fakturahistorik</h2><p class="text-muted mb-0">Grupperet på kunde og vare for de seneste 13 måneder. Forslag opretter aldrig noget automatisk.</p></div><div style="min-width:300px"><label class="form-label mc-label">Filtrér på virksomhed</label><select id="historyCompanyFilter" class="form-select" onchange="loadInvoiceHistory(view==='subscription_candidates')"><option value="">Alle virksomheder</option></select></div></div>
<div class="table-responsive"><table class="table table-hover mc-table mb-0"><thead><tr><th>Kunde</th><th>Vare</th><th>Fakturaer</th><th>Måneder</th><th>Periode</th><th>Beløb</th><th>Frekvens</th><th>Hub-match</th><th>Handling</th></tr></thead><tbody id="historyBody"></tbody></table></div>
</section>
<section id="caseImportPanel" class="card mc-stat d-none">
<div class="card-body p-4 p-lg-5">
<div class="row g-4 align-items-start">
<div class="col-12 col-xl-5">
<div class="mc-label mb-2">Enkeltvis, kontrolleret overførsel</div>
<h2 class="h4">Overfør vTiger-sag</h2>
<p class="text-muted">Indtast sagsnummeret fra vTiger, fx <strong>CC12345</strong>. Først vises en forhåndsvisning; intet ændres, før du bekræfter overførslen.</p>
<label class="form-label fw-semibold" for="vtigerTicketNumber">vTiger-sagsnummer</label>
<div class="input-group input-group-lg">
<input id="vtigerTicketNumber" class="form-control" maxlength="80" placeholder="CC12345" autocomplete="off">
<button id="vtigerPreviewButton" class="btn btn-primary" type="button" onclick="previewVtigerCase()"><i class="bi bi-search me-1"></i>Find sag</button>
</div>
<div class="form-text">Sagen skal allerede være knyttet til en kunde, som findes og er linket i Hub.</div>
<div id="vtigerImportAlert" class="alert d-none mt-3 mb-0" role="status" aria-live="polite"></div>
</div>
<div class="col-12 col-xl-7">
<div id="vtigerPreviewEmpty" class="mc-source-card h-100 d-flex align-items-center justify-content-center text-center text-muted py-5">Sagens kunde, status og registrerede tid vises her, inden du overfører.</div>
<div id="vtigerPreviewCard" class="mc-source-card d-none">
<div class="d-flex flex-wrap justify-content-between gap-2 align-items-start">
<div><div class="mc-label">Forhåndsvisning</div><h3 class="h5 mb-1" id="vtigerPreviewTitle"></h3><div class="text-muted" id="vtigerPreviewMeta"></div></div>
<div id="vtigerPreviewState"></div>
</div>
<p class="mt-3 mb-3" id="vtigerPreviewDescription"></p>
<div class="row g-2 mb-3">
<div class="col-6 col-md-3"><div class="mc-label">Kunde</div><div class="fw-semibold" id="vtigerPreviewCustomer"></div></div>
<div class="col-6 col-md-3"><div class="mc-label">Kontakt</div><div class="fw-semibold" id="vtigerPreviewContact"></div></div>
<div class="col-6 col-md-3"><div class="mc-label">Tidsregistreringer</div><div class="fw-semibold" id="vtigerPreviewTimes"></div></div>
<div class="col-6 col-md-3"><div class="mc-label">Prioritet</div><div class="fw-semibold" id="vtigerPreviewPriority"></div></div>
</div>
<div class="alert alert-info py-2 mb-3 small">Overførslen opretter Hub-sagen og knytter tidsregistreringerne. vTiger-sagen markeres først som “Hubbet”, når Hub-overførslen er gemt. Kommentarer vises kun som antal og kopieres ikke, da vTiger ikke fortæller sikkert, om de er interne.</div>
<button id="vtigerImportButton" class="btn btn-success" type="button" onclick="importVtigerCase()"><i class="bi bi-check2-circle me-1"></i>Overfør sag til Hub</button>
</div>
</div>
</div>
</div>
</section>
<section id="hubExportPanel" class="card mc-stat d-none">
<div class="card-body p-4 p-lg-5">
<div class="row g-4 align-items-start">
<div class="col-12 col-xl-4">
<div class="mc-label mb-2">Hub → VTiger</div>
<h2 class="h4">Overfør Hub-sag til VTiger</h2>
<p class="text-muted">Sagen og godkendte Hub-timer overføres efter forhåndsvisning. VTiger forbliver stedet, hvor faktureringen håndteres.</p>
<label class="form-label fw-semibold" for="hubExportSagId">Hub-sagsnummer</label>
<div class="input-group input-group-lg">
<span class="input-group-text">#</span><input id="hubExportSagId" class="form-control" type="number" min="1" step="1" placeholder="173">
<button id="hubExportPreviewButton" class="btn btn-primary" type="button" onclick="previewHubCaseExport()"><i class="bi bi-search me-1"></i>Find sag</button>
</div>
<div class="form-text">Kunden og en VTiger-linket primær kontakt skal være knyttet til sagen.</div>
<div id="hubExportAlert" class="alert d-none mt-3 mb-0" role="status" aria-live="polite"></div>
</div>
<div class="col-12 col-xl-8">
<div id="hubExportPreviewEmpty" class="mc-source-card h-100 d-flex align-items-center justify-content-center text-center text-muted py-5">Sagsfelter og godkendte tidsregistreringer vises her, inden du overfører.</div>
<div id="hubExportPreviewCard" class="mc-source-card d-none">
<div class="d-flex flex-wrap justify-content-between gap-2 align-items-start">
<div><div class="mc-label">Forhåndsvisning</div><h3 class="h5 mb-1" id="hubExportTitle"></h3><div class="text-muted" id="hubExportMeta"></div></div>
<div id="hubExportState"></div>
</div>
<p class="mt-3 mb-3" id="hubExportDescription"></p>
<div class="row g-2 mb-3">
<div class="col-6 col-md-3"><div class="mc-label">Kunde</div><div class="fw-semibold" id="hubExportCustomer"></div></div>
<div class="col-6 col-md-3"><div class="mc-label">Kontakt</div><div class="fw-semibold" id="hubExportContact"></div></div>
<div class="col-6 col-md-3"><div class="mc-label">VTiger-status</div><div class="fw-semibold" id="hubExportStatus"></div></div>
<div class="col-6 col-md-3"><div class="mc-label">Prioritet</div><div class="fw-semibold" id="hubExportPriority"></div></div>
</div>
<div class="d-flex justify-content-between align-items-center mb-2"><strong>Tider til VTiger</strong><span class="small text-muted" id="hubExportTimesSummary"></span></div>
<div class="table-responsive mb-3"><table class="table table-sm align-middle"><thead><tr><th>Dato</th><th>Tekniker</th><th>Beskrivelse</th><th class="text-end">Timer</th><th>Fakturerbar</th></tr></thead><tbody id="hubExportTimesBody"></tbody></table></div>
<div id="hubExportBlockers" class="alert alert-warning d-none py-2 small"></div>
<div class="alert alert-info py-2 mb-3 small">Kun godkendte, ikke fakturerede Hub-timer overføres. Timer markeret som faktura-afregning oprettes fakturerbare i VTiger; øvrige timer oprettes ikke-fakturerbare. Eksisterende VTiger-timer og faktureringsflag ændres ikke.</div>
<button id="hubExportButton" class="btn btn-success" type="button" onclick="exportHubCaseToVtiger()"><i class="bi bi-check2-circle me-1"></i>Overfør sag til VTiger</button>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
@ -118,6 +195,8 @@
<script>
const API='/api/v1/migration-center';
let sessions=[], sessionId=null, page=1, total=0, view='all', currentItem=null, pendingPreflight=null;
let vtigerPreview=null;
let hubExportPreviewData=null;
const modal=()=>bootstrap.Modal.getOrCreateInstance(document.getElementById('actionModal'));
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]));
async function api(path, options={}){
@ -125,6 +204,71 @@ async function api(path, options={}){
if(!res.ok){let d={};try{d=await res.json()}catch{};throw new Error(typeof d.detail==='object'?(d.detail.message||JSON.stringify(d.detail)):(d.detail||`HTTP ${res.status}`))}
return res.headers.get('content-type')?.includes('json')?res.json():res.text();
}
function setVtigerImportAlert(message, type='info'){
const el=document.getElementById('vtigerImportAlert');el.className=`alert alert-${type} mt-3 mb-0`;el.textContent=message;
}
async function previewVtigerCase(){
const input=document.getElementById('vtigerTicketNumber'),button=document.getElementById('vtigerPreviewButton'),number=input.value.trim().toUpperCase();
if(!/^CC[0-9]{1,12}$/.test(number)){setVtigerImportAlert('Indtast sagsnummer i formatet CC12345.','warning');input.focus();return}
button.disabled=true;button.innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Henter…';vtigerPreview=null;
document.getElementById('vtigerPreviewCard').classList.add('d-none');document.getElementById('vtigerPreviewEmpty').classList.remove('d-none');
try{
const d=await api('/vtiger-cases/preview',{method:'POST',body:JSON.stringify({ticket_number:number})});vtigerPreview=d;
document.getElementById('vtigerPreviewEmpty').classList.add('d-none');document.getElementById('vtigerPreviewCard').classList.remove('d-none');
document.getElementById('vtigerPreviewTitle').textContent=d.title;document.getElementById('vtigerPreviewMeta').textContent=`${d.ticket_number} · ${d.status}`;
document.getElementById('vtigerPreviewDescription').textContent=d.description||'Ingen beskrivelse';document.getElementById('vtigerPreviewCustomer').textContent=d.customer?.name||'—';
document.getElementById('vtigerPreviewContact').textContent=d.contact?[d.contact.first_name,d.contact.last_name].filter(Boolean).join(' ')||'—':'—';
document.getElementById('vtigerPreviewTimes').textContent=`${d.time_entries} · ${Number(d.time_hours).toFixed(2)} timer`;document.getElementById('vtigerPreviewPriority').textContent=d.priority;
document.getElementById('vtigerPreviewState').innerHTML=d.already_imported?`<span class="mc-badge mc-green">Allerede overført · Hub-sag #${esc(d.hub_sag_id)}</span>`:`<span class="mc-badge mc-blue">Klar til gennemgang</span>`;
const importButton=document.getElementById('vtigerImportButton');importButton.disabled=d.already_imported&&d.vtiger_marked;importButton.innerHTML=d.already_imported?(d.vtiger_marked?'Allerede overført og markeret':'Prøv VTiger-markering igen'):'Overfør sag til Hub';
setVtigerImportAlert(d.comments_available_not_imported?`${d.comments_available_not_imported} vTiger-kommentarer kopieres ikke, fordi deres synlighedstype ikke kan fastslås sikkert.`:'Forhåndsvisning klar. Ingen data er ændret.','info');
}catch(e){setVtigerImportAlert(e.message,'danger')}
finally{button.disabled=false;button.innerHTML='<i class="bi bi-search me-1"></i>Find sag'}
}
async function importVtigerCase(){
if(!vtigerPreview)return;const button=document.getElementById('vtigerImportButton');
const promptText=vtigerPreview.already_imported?'Forsøg at markere den eksisterende Hub-sag som Hubbet i vTiger?':'Overfør '+vtigerPreview.ticket_number+' til Hub med de viste tidsregistreringer og markér derefter vTiger-sagen som Hubbet?';
if(!window.confirm(promptText))return;
button.disabled=true;button.innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Overfører…';
try{
const d=await api('/vtiger-cases/import',{method:'POST',body:JSON.stringify({ticket_number:vtigerPreview.ticket_number})});
if(d.warning){await previewVtigerCase();setVtigerImportAlert(`${d.warning} Du kan trykke “Prøv VTiger-markering igen”; Hub-sagen bliver ikke oprettet på ny.`,'warning')}
else{await previewVtigerCase();setVtigerImportAlert(d.imported?`Sagen er overført til Hub som sag #${d.hub_sag_id}; ${d.time_entries} tidsregistreringer blev tilknyttet. vTiger er markeret Hubbet.`:`Hub-sag #${d.hub_sag_id} var allerede oprettet. vTiger er nu markeret Hubbet.`,'success')}
}catch(e){setVtigerImportAlert(e.message,'danger')}
finally{button.disabled=Boolean(vtigerPreview?.already_imported&&vtigerPreview?.vtiger_marked);if(!button.disabled)button.innerHTML=vtigerPreview?.already_imported?'Prøv VTiger-markering igen':'<i class="bi bi-check2-circle me-1"></i>Overfør sag til Hub'}
}
function setHubExportAlert(message,type='info'){
const el=document.getElementById('hubExportAlert');el.className=`alert alert-${type} mt-3 mb-0`;el.textContent=message;
}
async function previewHubCaseExport(){
const input=document.getElementById('hubExportSagId'),button=document.getElementById('hubExportPreviewButton'),sagId=Number(input.value);
if(!Number.isInteger(sagId)||sagId<1){setHubExportAlert('Indtast et gyldigt Hub-sagsnummer.','warning');input.focus();return}
button.disabled=true;button.innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Henter…';hubExportPreviewData=null;
document.getElementById('hubExportPreviewCard').classList.add('d-none');document.getElementById('hubExportPreviewEmpty').classList.remove('d-none');
try{
const d=await api('/hub-cases/preview',{method:'POST',body:JSON.stringify({sag_id:sagId})});hubExportPreviewData=d;
document.getElementById('hubExportPreviewEmpty').classList.add('d-none');document.getElementById('hubExportPreviewCard').classList.remove('d-none');
document.getElementById('hubExportTitle').textContent=d.title;document.getElementById('hubExportMeta').textContent=`Hub sag #${d.sag_id} · ${d.status||'—'} · VTiger: ${d.vtiger_case_id?d.ticket_number||d.vtiger_case_id:'ny sag'}`;
document.getElementById('hubExportDescription').textContent=d.description||'Ingen beskrivelse';document.getElementById('hubExportCustomer').textContent=d.customer_name||'—';document.getElementById('hubExportContact').textContent=d.contact_name||'—';document.getElementById('hubExportStatus').textContent=d.vtiger_status||'—';document.getElementById('hubExportPriority').textContent=d.vtiger_priority||'—';
document.getElementById('hubExportTimesSummary').textContent=`${d.time_entries} godkendte · ${Number(d.time_hours).toFixed(2)} timer${d.pending_time_count?` · ${d.pending_time_count} ikke-godkendte udeladt`:''}`;
document.getElementById('hubExportTimesBody').innerHTML=d.time_rows.length?d.time_rows.map(t=>`<tr><td>${esc(t.date||'—')}</td><td>${esc(t.technician)}</td><td>${esc(t.description||'Ingen beskrivelse')}</td><td class="text-end">${Number(t.hours).toFixed(2)}</td><td>${t.billable?'Ja':'Nej'}</td></tr>`).join(''):'<tr><td colspan="5" class="text-muted">Ingen nye, godkendte Hub-timer at overføre.</td></tr>';
const blockers=document.getElementById('hubExportBlockers');blockers.classList.toggle('d-none',!d.blockers.length);blockers.textContent=d.blockers.join(' ');
const buttonEl=document.getElementById('hubExportButton');buttonEl.disabled=d.blockers.length>0||d.already_exported&&d.time_entries===0;buttonEl.innerHTML=d.already_exported?(d.time_entries?'Fortsæt overførsel af manglende timer':'Allerede overført'):'<i class="bi bi-check2-circle me-1"></i>Overfør sag til VTiger';
setHubExportAlert(d.blockers.length?'Ret punkterne ovenfor før overførslen.':d.already_exported?'Sagen findes allerede i VTiger. Kun eventuelle manglende timer kan fortsættes.':'Forhåndsvisning klar. Ingen data er ændret.','info');
}catch(e){setHubExportAlert(e.message,'danger')}
finally{button.disabled=false;button.innerHTML='<i class="bi bi-search me-1"></i>Find sag'}
}
async function exportHubCaseToVtiger(){
const d=hubExportPreviewData;if(!d)return;
if(!window.confirm(`Overfør Hub-sag #${d.sag_id} og ${d.time_entries} godkendte tidsregistreringer til VTiger? Fakturering fortsætter i VTiger.`))return;
const button=document.getElementById('hubExportButton');button.disabled=true;button.innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Overfører…';
try{
const result=await api('/hub-cases/export',{method:'POST',body:JSON.stringify({sag_id:d.sag_id,source_hash:d.source_hash})});
await previewHubCaseExport();
setHubExportAlert(result.warning?`Sagen er ${result.created_case?'oprettet':'allerede linket'} i VTiger, men nogle timer mangler: ${result.warning} Du kan fortsætte uden at oprette sagen igen.`:`Sagen er ${result.created_case?'oprettet':'allerede linket'} i VTiger${result.ticket_number?` som ${result.ticket_number}`:''}; ${result.created_time_entries} tidsregistreringer overført.` ,result.warning?'warning':'success');
}catch(e){setHubExportAlert(e.message,'danger')}
finally{if(hubExportPreviewData)button.disabled=Boolean(hubExportPreviewData.blockers.length)||hubExportPreviewData.time_entries===0;button.innerHTML='Overfør sag til VTiger'}
}
function badge(value){
const green=['created_in_hub','linked_to_existing','verified','approved','locked','match_found'];
const red=['conflict','source_changed','lock_failed','no_match','rejected'];
@ -137,7 +281,12 @@ async function init(){
select.innerHTML=sessions.map(s=>`<option value="${s.id}">${esc(s.name)} · ${s.treated_count}/${s.item_count}</option>`).join('');
const remembered=Number(localStorage.getItem('migrationCenterSession'));
sessionId=(sessions.find(s=>s.id===remembered)||sessions[0])?.id||null;
if(!sessionId){document.getElementById('emptyState').classList.remove('d-none');return}
if(!sessionId){
document.getElementById('workspace').classList.remove('d-none');document.getElementById('stats').classList.add('d-none');
document.querySelectorAll('#mcTabs .nav-link').forEach(btn=>{if(!['case_import','hub_export'].includes(btn.dataset.view))btn.closest('.nav-item').classList.add('d-none')});
document.querySelector('#sessionSelect').closest('.d-flex').classList.add('d-none');
document.querySelector('[data-view="case_import"]').click();return
}
select.value=sessionId; select.onchange=()=>{sessionId=Number(select.value);localStorage.setItem('migrationCenterSession',sessionId);refresh()};
document.getElementById('workspace').classList.remove('d-none');
await refresh();
@ -339,9 +488,14 @@ document.querySelectorAll('#mcTabs .nav-link').forEach(btn=>btn.onclick=async()=
document.getElementById('queuePanel').classList.toggle('d-none',view!=='queue');
document.getElementById('auditPanel').classList.toggle('d-none',view!=='audit');
document.getElementById('historyPanel').classList.toggle('d-none',!['invoice_history','subscription_candidates'].includes(view));
document.getElementById('listPanel').classList.toggle('d-none',['queue','audit','invoice_history','subscription_candidates'].includes(view));
document.getElementById('caseImportPanel').classList.toggle('d-none',view!=='case_import');
document.getElementById('hubExportPanel').classList.toggle('d-none',view!=='hub_export');
document.getElementById('listPanel').classList.toggle('d-none',['queue','audit','invoice_history','subscription_candidates','case_import','hub_export'].includes(view));
if(['case_import','hub_export'].includes(view)){document.getElementById('queuePanel').classList.add('d-none');document.getElementById('auditPanel').classList.add('d-none');document.getElementById('historyPanel').classList.add('d-none');return}
await loadItems(1);
});
document.getElementById('vtigerTicketNumber').addEventListener('keydown',e=>{if(e.key==='Enter')previewVtigerCase()});
document.getElementById('hubExportSagId').addEventListener('keydown',e=>{if(e.key==='Enter')previewHubCaseExport()});
document.getElementById('prevPage').onclick=()=>loadItems(page-1);document.getElementById('nextPage').onclick=()=>loadItems(page+1);
document.getElementById('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter')loadItems(1)});
init().catch(e=>{document.getElementById('emptyState').classList.remove('d-none');document.getElementById('emptyState').querySelector('.card-body').innerHTML=`<div class="alert alert-danger">${esc(e.message)}</div>`});

View File

@ -229,7 +229,7 @@ def update_settings(payload: PlannerSettingsInput, current_user: dict = Depends(
def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str] = Query(default=[]),
case_type: list[str] = Query(default=[]), responsible_id: int | None = None,
department_id: int | None = None,
planning: Literal["all", "unplanned", "partial", "planned"] = "all", mine: bool = False,
planning: Literal["all", "unplanned", "partial", "planned", "overdue"] = "all", mine: bool = False,
urgent: bool = False, sort: Literal["priority", "remaining", "updated"] = "priority",
limit: int = Query(100, ge=1, le=300), current_user: dict = Depends(get_current_user)):
_require_view(current_user)
@ -239,7 +239,9 @@ def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str
COALESCE(s.estimated_minutes, 0) AS estimated_minutes,
COALESCE(used.used_minutes, 0) AS used_minutes,
used.latest_registration_at,
COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)::int AS planned_minutes
COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)::int AS planned_minutes,
BOOL_OR(a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE) AS overdue_planned,
MAX(a.ends_at) FILTER (WHERE a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE) AS overdue_planned_until
FROM sag_sager s JOIN customers c ON c.id=s.customer_id
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id LEFT JOIN planner_allocations a ON a.sag_id=s.id
LEFT JOIN groups department ON department.id=s.assigned_group_id
@ -266,6 +268,7 @@ def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str
if planning == "unplanned": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)=0"
elif planning == "partial": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)<COALESCE(s.estimated_minutes,0)"
elif planning == "planned": sql += " HAVING COALESCE(s.estimated_minutes,0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>=s.estimated_minutes"
elif planning == "overdue": sql += " HAVING BOOL_OR(a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE)"
order = {"priority": "CASE s.priority::text WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, s.updated_at DESC",
"remaining": "(COALESCE(s.estimated_minutes,0)-COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)) DESC",
"updated": "s.updated_at DESC"}[sort]
@ -290,6 +293,65 @@ def case_time_entries(sag_id: int, current_user: dict = Depends(get_current_user
return {"sag_id": sag_id, "total_minutes": sum(int(r.get("minutes") or 0) for r in rows), "items": rows}
@router.get("/cases/{sag_id}/allocations")
def case_allocations(sag_id: int, current_user: dict = Depends(get_current_user)):
"""Return the persisted plan for one case for the embedded case planner."""
_require_view(current_user)
case_row = execute_query_single(
"""SELECT s.id, s.titel, s.estimated_minutes, s.deferred_until,
s.deferred_until_case_id, s.deferred_until_status,
waiting.titel AS deferred_case_title, waiting.status AS deferred_case_current_status
FROM sag_sager s
LEFT JOIN sag_sager waiting ON waiting.id=s.deferred_until_case_id
WHERE s.id=%s AND s.deleted_at IS NULL""",
(sag_id,),
)
if not case_row:
raise HTTPException(404, "Sagen findes ikke")
rows = execute_query(
"""SELECT a.id, a.sag_id, a.user_id, a.starts_at, a.ends_at, a.note,
a.conflict_status, a.locked, a.version,
COALESCE(u.full_name,u.username,'Ukendt') AS user_name
FROM planner_allocations a
LEFT JOIN users u ON u.user_id=a.user_id
WHERE a.sag_id=%s AND a.deleted_at IS NULL
ORDER BY a.starts_at, a.id""",
(sag_id,),
) or []
planned_minutes = sum(_minutes(_aware(row["starts_at"]), _aware(row["ends_at"])) for row in rows)
subcases = execute_query(
"""SELECT child.id, child.titel, child.status, child.type,
child.ansvarlig_bruger_id, child.estimated_minutes,
child.deferred_until, child.deferred_until_case_id, child.deferred_until_status,
COALESCE(owner.full_name,owner.username) AS responsible_name,
waiting.titel AS deferred_case_title,
waiting.status AS deferred_case_current_status
FROM sag_relationer relation
JOIN sag_sager child ON child.id=relation.målsag_id AND child.deleted_at IS NULL
LEFT JOIN users owner ON owner.user_id=child.ansvarlig_bruger_id
LEFT JOIN sag_sager waiting ON waiting.id=child.deferred_until_case_id
WHERE relation.kilde_sag_id=%s AND LOWER(relation.relationstype)='undersag'
AND relation.deleted_at IS NULL
ORDER BY child.created_at, child.id""",
(sag_id,),
) or []
return {
"sag_id": sag_id,
"estimated_minutes": case_row.get("estimated_minutes"),
"planned_minutes": planned_minutes,
"remaining_minutes": max(0, int(case_row.get("estimated_minutes") or 0) - planned_minutes),
"deferred": {
"until": case_row.get("deferred_until"),
"case_id": case_row.get("deferred_until_case_id"),
"case_title": case_row.get("deferred_case_title"),
"case_status": case_row.get("deferred_case_current_status"),
"target_status": case_row.get("deferred_until_status"),
},
"subcases": subcases,
"items": rows,
}
@router.patch("/cases/{sag_id}/estimate")
def update_case_estimate(sag_id: int, payload: CaseEstimateInput, current_user: dict = Depends(get_current_user)):
permissions = _permissions(current_user)

View File

@ -7,6 +7,7 @@
.untimed-registers{position:absolute;left:28px;right:4px;bottom:4px;z-index:4;display:flex;flex-wrap:wrap;gap:4px;pointer-events:none}.untimed-register{max-width:100%;border:1px solid #75e0a7;border-left:3px solid #079455;border-radius:6px;background:#ecfdf3;color:#05603a;padding:3px 7px;font-size:.68rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.planner-page{width:100%;max-width:2200px;padding:.75rem}.planner-head{margin-bottom:.75rem}.planner-layout{grid-template-columns:minmax(340px,400px) minmax(0,1fr);gap:.75rem;min-height:calc(100vh - 185px)}.calendar-panel{min-width:0}.panel-title{padding:.8rem}.case-list{padding:.6rem;gap:.5rem}.case-card{padding:.68rem}.case-card strong{display:block;line-height:1.3;overflow-wrap:anywhere}.case-card .customer{margin-top:.18rem}.week-grid{min-width:840px}
@media(max-width:1050px){.planner-layout{grid-template-columns:1fr}.case-panel{max-height:390px}.case-panel.collapsed .panel-title>:not(.panel-heading),.case-panel.collapsed .case-list{display:none}.case-panel.collapsed{max-height:58px}.collapse-cases{display:inline-block!important}}.collapse-cases{display:none;border:0;background:transparent}
.quick-filters button[data-quick=overdue]{border-color:#f5b4ae;color:#b42318;font-weight:700}.quick-filters button[data-quick=overdue].active{background:#fef3f2;color:#b42318;border-color:#f04438}.overdue-case-section{border:1px solid #f5b4ae;border-radius:10px;background:#fff7f6;padding:.65rem}.overdue-case-heading{display:flex;align-items:center;gap:.4rem;color:#b42318;font-size:.8rem;font-weight:800;margin-bottom:.5rem}.case-card.overdue-planned{border-color:#f5b4ae;border-left-color:#d92d20;background:#fffafa}.case-overdue-label{display:inline-flex;align-items:center;gap:.25rem;color:#b42318;font-size:.73rem;font-weight:750;margin-top:.4rem}
</style>
{% endblock %}
{% block content %}
@ -15,7 +16,7 @@
<div class="planner-toolbar"><div class="planner-toggle" id="scopeToggle"><button data-scope="mine" class="active">Min plan</button><button data-scope="team">Team</button></div><div class="planner-toggle" id="viewToggle"><button data-view="day">Dag</button><button data-view="week" class="active">Uge</button><button data-view="month">Måned</button></div></div>
</header>
<section class="planner-layout">
<aside class="case-panel" id="casePanel"><div class="panel-title"><div class="panel-heading d-flex justify-content-between"><strong>Sager til planlægning</strong><button class="collapse-cases" aria-label="Fold sagsliste"><i class="bi bi-chevron-up"></i></button></div><div class="case-search mt-2"><input id="caseSearch" placeholder="Sagsnr., kunde, titel eller ansvarlig…"><i class="bi bi-search position-absolute end-0 me-2 mt-2 text-muted"></i></div><div class="filter-row"><select id="statusFilter"><option value="">Alle statusser</option><option>åben</option><option>i_gang</option><option>on_hold</option></select><select id="priorityFilter"><option value="">Alle prioriteter</option><option value="urgent">Haster</option><option value="high">Høj</option><option value="normal">Normal</option><option value="low">Lav</option></select><select id="typeFilter"><option value="">Alle sagstyper</option></select><select id="responsibleFilter"><option value="">Alle ansvarlige</option></select><select id="departmentFilter"><option value="">Alle afdelinger</option></select><select id="sortFilter"><option value="priority">Prioritet</option><option value="remaining">Resterende tid</option><option value="updated">Senest ændret</option></select></div><div class="quick-filters"><button data-quick="unplanned" class="active">Ikke planlagt</button><button data-quick="partial">Delvist planlagt</button><button data-quick="mine">Mine sager</button><button data-quick="urgent">Haster</button></div></div><div class="case-list" id="caseList"><div class="empty">Henter sager…</div></div></aside>
<aside class="case-panel" id="casePanel"><div class="panel-title"><div class="panel-heading d-flex justify-content-between"><strong>Sager til planlægning</strong><button class="collapse-cases" aria-label="Fold sagsliste"><i class="bi bi-chevron-up"></i></button></div><div class="case-search mt-2"><input id="caseSearch" placeholder="Sagsnr., kunde, titel eller ansvarlig…"><i class="bi bi-search position-absolute end-0 me-2 mt-2 text-muted"></i></div><div class="filter-row"><select id="statusFilter"><option value="">Alle statusser</option><option>åben</option><option>i_gang</option><option>on_hold</option></select><select id="priorityFilter"><option value="">Alle prioriteter</option><option value="urgent">Haster</option><option value="high">Høj</option><option value="normal">Normal</option><option value="low">Lav</option></select><select id="typeFilter"><option value="">Alle sagstyper</option></select><select id="responsibleFilter"><option value="">Alle ansvarlige</option></select><select id="departmentFilter"><option value="">Alle afdelinger</option></select><select id="sortFilter"><option value="priority">Prioritet</option><option value="remaining">Resterende tid</option><option value="updated">Senest ændret</option></select></div><div class="quick-filters"><button data-quick="overdue">Overskredet plan</button><button data-quick="unplanned" class="active">Ikke planlagt</button><button data-quick="partial">Delvist planlagt</button><button data-quick="mine">Mine sager</button><button data-quick="urgent">Haster</button></div></div><div class="case-list" id="caseList"><div class="empty">Henter sager…</div></div></aside>
<section class="calendar-panel"><div class="calendar-top"><div class="planner-toolbar"><button id="prevPeriod" aria-label="Forrige"><i class="bi bi-chevron-left"></i></button><button id="todayBtn">I dag</button><button id="nextPeriod" aria-label="Næste"><i class="bi bi-chevron-right"></i></button><span class="period-label" id="periodLabel"></span></div><div class="capacity-legend"><span><i class="dot green"></i>Ledig</span><span><i class="dot yellow"></i>Næsten fuld</span><span><i class="dot red"></i>Fuld/overbooket</span><span><i class="dot gray"></i>Fri/fravær</span></div></div><div class="timeline" id="timeline"></div></section>
</section>
</main>
@ -23,4 +24,4 @@
<dialog class="allocation-dialog" id="timeDialog"><form method="dialog"><div class="dialog-body"><small class="text-muted">Faktisk registreret tid</small><h5 id="timeDialogTitle"></h5><div id="timeDialogTotal" class="mb-3 fw-semibold"></div><div id="timeEntryList" class="d-flex flex-column gap-2"></div></div><div class="dialog-actions"><button value="cancel" class="btn btn-light">Luk</button><a id="timeDialogCaseLink" class="btn btn-primary">Åbn sag</a></div></form></dialog>
<div class="toast-stack" id="toastStack"></div>
{% endblock %}
{% block extra_js %}<script src="/static/js/planner.js?v=5"></script>{% endblock %}
{% block extra_js %}<script src="/static/js/planner.js?v=7"></script>{% endblock %}

View File

@ -15,7 +15,7 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection
from app.core.database import execute_query, execute_query_single, execute_update, table_has_column, get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
from app.core.config import settings
@ -108,74 +108,33 @@ def _ensure_case_internet_connection_tag(sag_id: int) -> None:
def _normalize_case_status(status_value: Optional[str]) -> str:
allowed_statuses = []
seen = set()
def _add_status(value: Optional[str]) -> None:
candidate = str(value or "").strip()
if not candidate:
return
key = candidate.lower()
if key in seen:
return
seen.add(key)
allowed_statuses.append(candidate)
try:
setting_row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("case_statuses",))
if setting_row and setting_row.get("value"):
parsed = json.loads(setting_row.get("value") or "[]")
for item in parsed if isinstance(parsed, list) else []:
if isinstance(item, str):
value = item.strip()
elif isinstance(item, dict):
value = str(item.get("value") or "").strip()
else:
value = ""
_add_status(value)
except Exception:
pass
# Include historical/current DB statuses so legacy values remain valid
try:
rows = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
for row in rows:
_add_status(row.get("status"))
except Exception:
pass
if not allowed_statuses:
allowed_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
allowed_map = {s.lower(): s for s in allowed_statuses}
open_aliases = {"åben", "open", "under behandling", "afventer", "i_gang", "on_hold"}
closed_aliases = {"lukket", "closed", "løst", "afsluttet", "resolved", "done"}
open_default = allowed_map.get("åben", allowed_statuses[0])
closed_default = allowed_map.get("lukket", allowed_map.get("løst", open_default))
if not status_value:
return open_default
normalized = str(status_value).strip().lower()
if normalized in allowed_map:
return allowed_map[normalized]
if normalized in open_aliases:
return open_default
if normalized in closed_aliases:
return closed_default
# Backward compatibility for legacy mapping
if normalized == "afventer" and "åben" in allowed_map:
return open_default
# Do not force unknown values back to default; preserve user-entered/custom DB values
raw_value = str(status_value).strip()
if raw_value:
return raw_value
return open_default
canonical = {
"ny": "Ny",
"tildelt": "Tildelt",
"igangværende": "Igangværende",
"afventer": "Afventer",
"løst": "Løst",
"kunde svaret": "Kunde svaret",
"lukket": "Lukket",
}
legacy_aliases = {
"åben": "Ny",
"open": "Ny",
"under behandling": "Igangværende",
"i_gang": "Igangværende",
"in progress": "Igangværende",
"on_hold": "Afventer",
"resolved": "Løst",
"closed": "Lukket",
"afsluttet": "Lukket",
"done": "Lukket",
}
normalized = str(status_value or "Ny").strip().lower()
if normalized in canonical:
return canonical[normalized]
if normalized in legacy_aliases:
return legacy_aliases[normalized]
raise HTTPException(status_code=400, detail="Ugyldig status. Vælg en af de faste sagsstatuser.")
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
@ -442,6 +401,12 @@ class SagListPreferencesUpdate(BaseModel):
hidden_columns: Optional[List[str]] = None
class CaseAssignmentsUpdate(BaseModel):
group_id: Optional[int] = None
user_ids: List[int] = Field(default_factory=list)
primary_user_id: Optional[int] = None
SAG_LIST_COLUMN_KEYS = (
"id", "company", "contact", "description", "type", "priority", "status",
"owner", "group", "next_todo", "created", "start", "deferred", "deadline",
@ -1022,23 +987,49 @@ async def create_sag(request: Request, data: dict):
try:
if not data.get("titel"):
raise HTTPException(status_code=400, detail="titel is required")
if not data.get("customer_id"):
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
customer_required_types = {"ticket", "salg", "bogholderi", "projekt", "bug_report", "service", "pipeline", "ordre", "opgave", "abonnement"}
if case_type == "intern_ticket":
# Internal work always belongs to BMC Networks and is owned by its creator.
data["customer_id"] = 8
data["customer_id"] = _coerce_optional_int(data.get("customer_id"), "customer_id")
if case_type in customer_required_types and not data.get("customer_id"):
raise HTTPException(status_code=400, detail="customer_id is required")
_validate_customer_id(data.get("customer_id"))
status = _normalize_case_status(data.get("status"))
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
current_user_id = _get_user_id_from_request(request)
raw_responsible = data.get("ansvarlig_bruger_id") if "ansvarlig_bruger_id" in data else current_user_id
if case_type == "intern_ticket":
raw_responsible = current_user_id
ansvarlig_bruger_id = _coerce_optional_int(raw_responsible, "ansvarlig_bruger_id")
assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id")
_validate_user_id(ansvarlig_bruger_id)
_validate_group_id(assigned_group_id)
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
pipeline = data.get("pipeline")
order_items = data.get("order_items", [])
type_details = data.get("type_details") or {}
if not isinstance(type_details, dict):
raise HTTPException(status_code=400, detail="type_details skal være et objekt")
if len(type_details) > 20:
raise HTTPException(status_code=400, detail="For mange typefelter")
normalized_type_details = {}
for key, value in type_details.items():
if not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", str(key)):
raise HTTPException(status_code=400, detail="Ugyldigt typefeltnavn")
if value is None:
continue
if not isinstance(value, (str, int, float, bool)):
raise HTTPException(status_code=400, detail="Ugyldig værdi i typefelt")
normalized = str(value).strip()
if len(normalized) > 10000:
raise HTTPException(status_code=400, detail="Et typefelt er for langt")
if normalized:
normalized_type_details[str(key)] = normalized
raw_contact_ids = data.get("contact_ids") or []
if not isinstance(raw_contact_ids, list):
raise HTTPException(status_code=400, detail="contact_ids skal være en liste")
@ -1132,12 +1123,12 @@ async def create_sag(request: Request, data: dict):
cursor.execute(
"""
INSERT INTO sag_sager
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status,
(titel, beskrivelse, template_key, type, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status,
pipeline_amount, pipeline_probability, pipeline_stage_id, pipeline_description)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
(data.get("titel"), data.get("beskrivelse", ""), case_type, case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
assigned_group_id, current_user_id, deadline, deferred_until, data.get("deferred_until_case_id"),
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
)
@ -1145,6 +1136,34 @@ async def create_sag(request: Request, data: dict):
if not result:
raise HTTPException(status_code=500, detail="Failed to create case")
if normalized_type_details:
cursor.execute(
"""INSERT INTO sag_case_details (sag_id, details, created_by_user_id, updated_by_user_id)
VALUES (%s, %s::jsonb, %s, %s)""",
(result["id"], json.dumps(normalized_type_details), current_user_id, current_user_id),
)
anydesk_id = normalized_type_details.get("anydesk_id")
if case_type == "ticket" and anydesk_id:
if len(anydesk_id) > 64 or not re.fullmatch(r"[0-9][0-9 .-]*", anydesk_id):
raise HTTPException(status_code=400, detail="Ugyldigt AnyDesk-ID")
cursor.execute(
"""INSERT INTO sag_anydesk_ids (sag_id, anydesk_id, is_primary, created_by_user_id)
VALUES (%s, %s, TRUE, %s)
ON CONFLICT (sag_id, anydesk_id) DO UPDATE SET deleted_at=NULL, is_primary=TRUE, updated_at=NOW()""",
(result["id"], anydesk_id, current_user_id),
)
cursor.execute("UPDATE sag_anydesk_ids SET is_primary=FALSE WHERE sag_id=%s AND anydesk_id<>%s AND deleted_at IS NULL", (result["id"], anydesk_id))
solution = normalized_type_details.get("solution")
if case_type == "ticket" and solution:
cursor.execute(
"""INSERT INTO sag_solutions (sag_id, title, description, solution_type, result, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (sag_id) DO NOTHING""",
(result["id"], data.get("titel"), solution, "case_solution", "draft", current_user_id),
)
if contact_ids:
cursor.execute("SELECT id FROM contacts WHERE id = ANY(%s)", (contact_ids,))
existing_contact_ids = {int(row["id"]) for row in cursor.fetchall()}
@ -1514,11 +1533,22 @@ async def update_my_sag_list_preferences(request: Request, payload: SagListPrefe
@router.get("/sag/{sag_id}/modules")
async def get_case_module_prefs(sag_id: int):
"""Get module visibility preferences for a case."""
async def get_case_module_prefs(sag_id: int, current_user: dict = Depends(get_current_user)):
"""Get module visibility preferences for the current user on a case."""
try:
query = "SELECT module_key, is_enabled FROM sag_module_prefs WHERE sag_id = %s"
prefs = execute_query(query, (sag_id,))
user_id = int(current_user["id"])
query = """
SELECT module_key, is_enabled
FROM sag_module_prefs
WHERE sag_id = %s
AND module_key <> 'buzzwords'
UNION ALL
SELECT module_key, is_enabled
FROM user_sag_module_prefs
WHERE sag_id = %s
AND user_id = %s
"""
prefs = execute_query(query, (sag_id, sag_id, user_id))
return prefs or []
except Exception as e:
logger.error("❌ Error getting module prefs: %s", e)
@ -1526,8 +1556,12 @@ async def get_case_module_prefs(sag_id: int):
@router.post("/sag/{sag_id}/modules")
async def set_case_module_pref(sag_id: int, data: dict):
"""Set module visibility preference for a case."""
async def set_case_module_pref(
sag_id: int,
data: dict,
current_user: dict = Depends(get_current_user),
):
"""Set a module visibility preference, scoped per user for Buzzwords."""
try:
module_key = data.get("module_key")
is_enabled = data.get("is_enabled")
@ -1535,6 +1569,18 @@ async def set_case_module_pref(sag_id: int, data: dict):
if not module_key or is_enabled is None:
raise HTTPException(status_code=400, detail="module_key and is_enabled are required")
if module_key == "buzzwords":
if not _table_exists("user_sag_module_prefs"):
raise HTTPException(status_code=409, detail="Buzzwords-indstillingen er ikke klar. Kør migration 1042 først.")
query = """
INSERT INTO user_sag_module_prefs (user_id, sag_id, module_key, is_enabled)
VALUES (%s, %s, %s, %s)
ON CONFLICT (user_id, sag_id, module_key)
DO UPDATE SET is_enabled = EXCLUDED.is_enabled
RETURNING module_key, is_enabled
"""
result = execute_query(query, (int(current_user["id"]), sag_id, module_key, bool(is_enabled)))
else:
query = """
INSERT INTO sag_module_prefs (sag_id, module_key, is_enabled)
VALUES (%s, %s, %s)
@ -1555,6 +1601,83 @@ async def set_case_module_pref(sag_id: int, data: dict):
# SAG TODO STEPS
# =========================================================================
@router.get("/sag/{sag_id}/assignments", dependencies=[Depends(case_edit_access)])
async def get_case_assignments(sag_id: int, group_id: Optional[int] = Query(None)):
"""Return selected employees and, when a group is chosen, its available members."""
case_row = execute_query_single(
"SELECT id, assigned_group_id, ansvarlig_bruger_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL",
(sag_id,),
)
if not case_row:
raise HTTPException(status_code=404, detail="Case not found")
effective_group_id = group_id if group_id is not None else case_row.get("assigned_group_id")
selected = execute_query(
"""
SELECT u.user_id, COALESCE(NULLIF(TRIM(u.full_name), ''), u.username) AS display_name
FROM sag_case_assignees a JOIN users u ON u.user_id=a.user_id
WHERE a.sag_id=%s ORDER BY display_name
""",
(sag_id,),
) or []
members = []
if effective_group_id:
_validate_group_id(effective_group_id)
members = execute_query(
"""
SELECT u.user_id, COALESCE(NULLIF(TRIM(u.full_name), ''), u.username) AS display_name
FROM user_groups ug JOIN users u ON u.user_id=ug.user_id
WHERE ug.group_id=%s AND u.is_active=true ORDER BY display_name
""",
(effective_group_id,),
) or []
return {
"group_id": effective_group_id,
"primary_user_id": case_row.get("ansvarlig_bruger_id"),
"selected_user_ids": [row["user_id"] for row in selected],
"members": members,
}
@router.put("/sag/{sag_id}/assignments", dependencies=[Depends(case_edit_access)])
async def save_case_assignments(sag_id: int, payload: CaseAssignmentsUpdate, request: Request):
"""Persist every selected employee as its own assignment row."""
case_row = execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,))
if not case_row:
raise HTTPException(status_code=404, detail="Case not found")
user_ids = list(dict.fromkeys(payload.user_ids))
if payload.group_id is None:
if user_ids:
raise HTTPException(status_code=400, detail="Vælg en gruppe før medarbejdere tildeles")
else:
_validate_group_id(payload.group_id)
group_members = execute_query(
"SELECT user_id FROM user_groups WHERE group_id=%s", (payload.group_id,)
) or []
permitted_user_ids = {row["user_id"] for row in group_members}
invalid_user_ids = [user_id for user_id in user_ids if user_id not in permitted_user_ids]
if invalid_user_ids:
raise HTTPException(status_code=400, detail="Alle valgte medarbejdere skal være medlem af den valgte gruppe")
primary_user_id = payload.primary_user_id
if primary_user_id is not None and primary_user_id not in user_ids:
raise HTTPException(status_code=400, detail="Primær ansvarlig skal være blandt de valgte medarbejdere")
if primary_user_id is None and user_ids:
primary_user_id = user_ids[0]
user_id = _get_user_id_from_request(request)
execute_query("DELETE FROM sag_case_assignees WHERE sag_id=%s", (sag_id,))
for assignee_id in user_ids:
execute_query(
"INSERT INTO sag_case_assignees (sag_id, user_id, assigned_by_user_id) VALUES (%s, %s, %s)",
(sag_id, assignee_id, user_id),
)
execute_query(
"UPDATE sag_sager SET assigned_group_id=%s, ansvarlig_bruger_id=%s WHERE id=%s",
(payload.group_id, primary_user_id, sag_id),
)
return await get_case_assignments(sag_id, payload.group_id)
@router.get("/sag/{sag_id}/todo-steps", response_model=List[TodoStep])
async def list_todo_steps(sag_id: int):
try:
@ -1725,6 +1848,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
"""Update a case."""
try:
confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True
awaiting_todo = updates.pop("awaiting_todo", None)
# Check if case exists
check = execute_query(
"""
@ -1743,13 +1867,45 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
# Backwards compatibility: frontend sends "type", DB stores "template_key"
if "type" in updates and "template_key" not in updates:
updates["template_key"] = updates.get("type")
if "template_key" in updates:
updates["type"] = updates["template_key"]
if str(updates.get("template_key") or "").strip().lower() == "intern_ticket":
# Keep the same invariant when an existing case is changed to Internal ticket.
updates["customer_id"] = 8
updates["ansvarlig_bruger_id"] = _get_user_id_from_request(request)
if "status" in updates:
updates["status"] = _normalize_case_status(updates.get("status"))
closing_statuses = {"lukket", "løst", "afsluttet", "closed", "resolved", "done"}
closing_statuses = {"lukket"}
new_status = str(updates.get("status") or "").strip().lower()
case_type = str(previous_row.get("template_key") or "").strip().lower()
is_support_case = case_type in {"", "support", "ticket", "quickcreate", "service"}
if new_status in closing_statuses and previous_status not in closing_statuses:
unfinished_time = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM tmodule_times
WHERE sag_id = %s
AND (
slut_tid IS NULL
OR COALESCE(
entry_status,
CASE WHEN status IN ('approved', 'billed') THEN 'godkendt' ELSE 'afventer' END
) <> 'godkendt'
)
""",
(sag_id,),
) or {}
unfinished_count = int(unfinished_time.get("count") or 0)
if unfinished_count:
raise HTTPException(
status_code=409,
detail={
"code": "case_close_blocked_unfinished_time",
"message": f"Sagen kan ikke lukkes, fordi {unfinished_count} tidsregistrering(er) stadig er aktive, i kladde eller afventer godkendelse.",
},
)
if new_status in closing_statuses and previous_status not in closing_statuses and is_support_case:
has_registered_time = execute_query_single(
"SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s) AS has_time",
@ -1763,6 +1919,26 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
"message": "Der er ikke registreret tid på supportsagen. Bekræft at den skal lukkes uden tid.",
},
)
is_awaiting_transition = new_status.startswith("afventer") and not previous_status.startswith("afventer")
if is_awaiting_transition:
if not isinstance(awaiting_todo, dict):
raise HTTPException(
status_code=400,
detail="En TODO med hvad der afventes er påkrævet, før sagen kan sættes til Afventer",
)
todo_title = str(awaiting_todo.get("title") or "").strip()
if len(todo_title) < 3:
raise HTTPException(
status_code=400,
detail="TODO-titlen skal beskrive, hvad der afventes",
)
awaiting_todo = {
"title": todo_title,
"description": str(awaiting_todo.get("description") or "").strip() or None,
"due_date": awaiting_todo.get("due_date") or None,
}
else:
is_awaiting_transition = False
if "deadline" in updates:
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
if "start_date" in updates:
@ -1805,6 +1981,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
"titel",
"beskrivelse",
"template_key",
"type",
"status",
"customer_id",
"ansvarlig_bruger_id",
@ -1833,6 +2010,28 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
set_sql = ", ".join(set_clauses)
query = f"UPDATE sag_sager SET {set_sql} WHERE id = %s RETURNING *"
# Create the follow-up before changing status: an awaiting case must never
# be persisted without its required next action.
if is_awaiting_transition and awaiting_todo:
try:
todo_user_id = _get_user_id_from_request(request)
execute_query(
"""
INSERT INTO sag_todo_steps (sag_id, title, description, due_date, created_by_user_id, is_next)
VALUES (%s, %s, %s, %s, %s, true)
""",
(
sag_id,
awaiting_todo["title"],
awaiting_todo["description"],
awaiting_todo["due_date"],
todo_user_id,
),
)
except Exception as todo_error:
logger.error("Could not create mandatory awaiting TODO for case %s: %s", sag_id, todo_error)
raise HTTPException(status_code=500, detail="TODO kunne ikke oprettes; status er ikke ændret")
result = execute_query(query, tuple(params))
if result:
updated_row = result[0] or {}
@ -1869,6 +2068,12 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
type_labels = {
"ticket": "Ticket",
"salg": "Salg",
"bogholderi": "Bogholderi",
"admin": "Admin",
"postit": "Post-it",
"intern_ticket": "Intern ticket",
"bug_report": "Bug_report",
"pipeline": "Pipeline",
"opgave": "Opgave",
"ordre": "Ordre",
@ -1973,6 +2178,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
class BeskrivelsePatch(BaseModel):
beskrivelse: str
append: bool = False
@router.patch("/sag/{sag_id}/beskrivelse", dependencies=[Depends(case_edit_access)])
@ -1987,8 +2193,6 @@ async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: R
raise HTTPException(status_code=404, detail="Case not found")
old_beskrivelse = row.get("beskrivelse")
new_beskrivelse = body.beskrivelse
# Resolve acting user (may be None for anonymous)
user_id = _get_user_id_from_request(request)
changed_by_name = None
@ -2000,6 +2204,18 @@ async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: R
if u:
changed_by_name = u["name"]
if body.append:
existing_beskrivelse = str(old_beskrivelse or "").rstrip()
addition = body.beskrivelse.strip()
name_parts = re.findall(r"[A-Za-zÆØÅæøåÀ-ÖØ-öø-ÿ0-9]+", str(changed_by_name or "Bruger"))
initials = "".join(part[0].upper() for part in name_parts[:2]) or "BR"
append_date = datetime.now().strftime("%d/%m")
safe_addition = html.escape(addition).replace("\n", "<br>")
append_fragment = f"<hr><h6>{initials}/{append_date}</h6><p>{safe_addition}</p>"
new_beskrivelse = f"{existing_beskrivelse}\n{append_fragment}" if existing_beskrivelse else append_fragment
else:
new_beskrivelse = body.beskrivelse
# Write history entry
execute_query(
"""INSERT INTO sag_beskrivelse_history
@ -2458,12 +2674,12 @@ async def list_case_customers(sag_id: int):
raise HTTPException(status_code=500, detail="Failed to list case customers")
@router.post("/sag/{sag_id}/customer/replace")
async def replace_case_customer(sag_id: int, payload: dict):
@router.post("/sag/{sag_id}/customer/replace", dependencies=[Depends(case_edit_access)])
async def replace_case_customer(sag_id: int, payload: dict, request: Request):
"""Replace the primary customer on a case and keep relations synchronized."""
try:
existing_case = execute_query_single(
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
"SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,),
)
if not existing_case:
@ -2475,6 +2691,10 @@ async def replace_case_customer(sag_id: int, payload: dict):
_validate_customer_id(customer_id)
old_customer_id = existing_case.get("customer_id")
old_customer = execute_query_single("SELECT name FROM customers WHERE id = %s", (old_customer_id,)) if old_customer_id else None
new_customer = execute_query_single("SELECT name FROM customers WHERE id = %s", (customer_id,))
if table_has_column("sag_sager", "customer_id"):
execute_query(
"UPDATE sag_sager SET customer_id = %s WHERE id = %s",
@ -2517,6 +2737,32 @@ async def replace_case_customer(sag_id: int, payload: dict):
(sag_id, customer_id, "Kunde"),
)
if old_customer_id != customer_id:
try:
changed_by = "System"
try:
user_id = _get_user_id_from_request(request)
user = execute_query_single(
"SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), ''), CONCAT('Bruger #', user_id::text)) AS name FROM users WHERE user_id = %s",
(user_id,),
)
changed_by = str((user or {}).get("name") or changed_by)
except Exception:
pass
execute_query(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, true)
""",
(
sag_id,
changed_by,
f"🏢 Firma ændret: {(old_customer or {}).get('name') or 'Ingen kunde'} → {(new_customer or {}).get('name') or f'Kunde #{customer_id}'}",
),
)
except Exception as exc:
logger.warning("Could not write customer change history for case %s: %s", sag_id, exc)
logger.info("✅ Primary customer replaced for case %s -> customer %s", sag_id, customer_id)
return {"status": "ok", "sag_id": sag_id, "customer_id": customer_id}
except HTTPException:
@ -3826,15 +4072,143 @@ async def get_case_linked_activities(sag_id: int):
return {"activities": activities, "total": len(activities)}
def _normalized_case_relation_sql() -> str:
return """SELECT
CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id
ELSE kilde_sag_id END AS parent_id,
CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id
ELSE målsag_id END AS child_id,
relationstype
FROM sag_relationer WHERE deleted_at IS NULL"""
def _load_case_tree(sag_id: int, include_all: bool = True) -> tuple[list[dict], bool]:
"""Return the focused case's whole connected parent/child tree, cycle safe."""
relations = execute_query(
f"""WITH RECURSIVE normalized AS ({_normalized_case_relation_sql()}),
connected(case_id) AS (
SELECT %s
UNION
SELECT CASE WHEN n.parent_id = c.case_id THEN n.child_id ELSE n.parent_id END
FROM connected c JOIN normalized n
ON n.parent_id = c.case_id OR n.child_id = c.case_id
)
SELECT DISTINCT s.id, s.titel, s.status, s.type
FROM connected c JOIN sag_sager s ON s.id = c.case_id
WHERE s.deleted_at IS NULL ORDER BY s.id""",
(sag_id,),
) or []
if not include_all:
relations = [row for row in relations if row.get("id") == sag_id]
ids = {row.get("id") for row in relations}
if not ids:
return [], False
marks = ",".join(["%s"] * len(ids))
edges = execute_query(
f"""WITH normalized AS ({_normalized_case_relation_sql()})
SELECT n.parent_id, n.child_id, n.relationstype,
p.titel AS parent_title, c.titel AS child_title
FROM normalized n
JOIN sag_sager p ON p.id = n.parent_id AND p.deleted_at IS NULL
JOIN sag_sager c ON c.id = n.child_id AND c.deleted_at IS NULL
WHERE n.parent_id IN ({marks}) AND n.child_id IN ({marks})
ORDER BY n.parent_id, n.child_id""",
tuple(ids) + tuple(ids),
) or []
nodes = {row["id"]: {"id": row["id"], "title": row.get("titel") or f"Sag #{row['id']}", "children": []}
for row in relations}
parents: dict[int, list[int]] = {case_id: [] for case_id in ids}
for edge in edges:
parent_id, child_id = edge.get("parent_id"), edge.get("child_id")
if parent_id not in nodes or child_id not in nodes:
continue
parents[child_id].append(parent_id)
nodes[parent_id]["children"].append({
"id": child_id, "title": nodes[child_id]["title"],
"relation_type": edge.get("relationstype") or "",
})
def prune(node_id: int, path: set[int]) -> dict:
node = dict(nodes[node_id])
node["is_current"] = node_id == sag_id
node["relation"] = "current" if node_id == sag_id else ("parent" if sag_id in _descendant_ids(node_id, nodes) else "child")
children = []
for child in node["children"]:
child_id = child["id"]
if child_id in path:
continue
children.append({**prune(child_id, path | {child_id}), "relation_type": child.get("relation_type")})
node["children"] = children
return node
# Render each root so multiple parent branches remain visible. A pure cycle
# has no root; start at the focused case and let the visited path break it.
root_ids = sorted(case_id for case_id in ids if not parents.get(case_id))
if not root_ids:
root_ids = [sag_id]
return [prune(root_id, {root_id}) for root_id in root_ids], False
def _descendant_ids(node_id: int, nodes: dict[int, dict]) -> set[int]:
found: set[int] = set()
stack = list(nodes.get(node_id, {}).get("children", []))
while stack:
item = stack.pop()
child_id = item.get("id")
if child_id in found:
continue
found.add(child_id)
stack.extend(nodes.get(child_id, {}).get("children", []))
return found
def _ancestors_for_case(case_id: int, parents_by_child: dict[int, set[int]]) -> set[int]:
found: set[int] = set()
pending = list(parents_by_child.get(case_id, set()))
while pending:
parent_id = pending.pop()
if parent_id == case_id or parent_id in found:
continue
found.add(parent_id)
pending.extend(parents_by_child.get(parent_id, set()))
return found
@router.get("/sag/{sag_id}/case-tree")
async def get_sag_case_tree(sag_id: int):
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
if not check:
raise HTTPException(status_code=404, detail="Case not found")
tree, truncated = _load_case_tree(sag_id)
return {"tree": tree, "truncated": truncated, "max_depth": None}
@router.get("/sag/{sag_id}/timeline")
async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
async def get_sag_timeline(
sag_id: int,
include_subcases: bool = Query(False),
whole_tree: bool = Query(False),
relation_types: Optional[str] = Query(None),
event_types: Optional[str] = Query(None),
search: Optional[str] = Query(None, max_length=200),
limit: Optional[int] = Query(None, ge=1, le=200),
cursor: Optional[str] = Query(None, max_length=1000),
):
"""Return a unified timeline for the case with optional child cases."""
try:
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
if not check:
raise HTTPException(status_code=404, detail="Case not found")
if include_subcases:
if whole_tree:
tree_roots, tree_truncated = _load_case_tree(sag_id)
case_rows = []
def collect_tree_cases(nodes: list[dict]) -> None:
for node in nodes:
case_rows.append({"id": node["id"], "titel": node.get("title")})
collect_tree_cases(node.get("children") or [])
collect_tree_cases(tree_roots)
elif include_subcases:
case_tree_query = """
WITH RECURSIVE normalized_relations AS (
SELECT
@ -3866,6 +4240,7 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
"""
case_rows = execute_query(case_tree_query, (sag_id,)) or []
else:
tree_truncated = False
case_rows = execute_query(
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,)
@ -4085,6 +4460,7 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
COALESCE(NULLIF(TRIM(recorder.full_name), ''), NULLIF(TRIM(recorder.username), ''),
NULLIF(TRIM(t.user_name), ''), 'System') AS actor_name,
COALESCE(NULLIF(TRIM(employee.full_name), ''), NULLIF(TRIM(employee.username), ''),
NULLIF(TRIM(t.user_name), ''),
CONCAT('Medarbejder #', t.medarbejder_id::text)) AS employee_name,
s.titel AS source_sag_titel
FROM tmodule_times t
@ -4194,12 +4570,64 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
except Exception:
return datetime.min
events.sort(key=_sort_key, reverse=True)
relation_edges = execute_query(
f"""WITH normalized AS ({_normalized_case_relation_sql()})
SELECT parent_id, child_id FROM normalized
WHERE parent_id IN ({placeholders}) OR child_id IN ({placeholders})""",
tuple(case_ids) + tuple(case_ids),
) or []
parents_by_child: dict[int, set[int]] = {}
for edge in relation_edges:
parents_by_child.setdefault(edge["child_id"], set()).add(edge["parent_id"])
current_ancestors = _ancestors_for_case(sag_id, parents_by_child)
for event in events:
event_case_id = event.get("sag_id")
event["relation_type"] = "current" if event_case_id == sag_id else (
"parent" if event_case_id in current_ancestors else "child"
)
events = [event for event in events if event.get("timestamp")]
if relation_types:
selected_relations = {part.strip().lower() for part in relation_types.split(",") if part.strip()}
events = [event for event in events if event.get("relation_type") in selected_relations]
if event_types:
selected_types = {part.strip().lower() for part in event_types.split(",") if part.strip()}
events = [event for event in events if str(event.get("event_type") or "").lower() in selected_types]
if search:
needle = search.casefold()
events = [event for event in events if needle in " ".join(str(event.get(key) or "") for key in
("title", "description", "sag_titel", "forfatter", "source")).casefold()]
events.sort(key=lambda item: (_sort_key(item), str(item.get("id") or "")), reverse=True)
total = len(events)
if limit is not None:
if cursor:
try:
decoded = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)).decode("utf-8")
cursor_ts, cursor_id = decoded.split("|", 1)
cursor_time = _sort_key({"timestamp": cursor_ts})
except Exception:
raise HTTPException(status_code=400, detail="Invalid timeline cursor")
events = [event for event in events if (_sort_key(event), str(event.get("id") or "")) < (cursor_time, cursor_id)]
page = events[:limit]
has_more = len(events) > limit
next_cursor = None
if has_more and page:
last = page[-1]
raw_timestamp = str(last.get("timestamp") or "")
next_cursor = base64.urlsafe_b64encode(f"{raw_timestamp}|{last.get('id') or ''}".encode()).decode().rstrip("=")
events = page
else:
has_more = False
next_cursor = None
return {
"case_tree": case_rows,
"events": events,
"total": len(events),
"total": total,
"next_cursor": next_cursor,
"has_more": has_more,
"truncated": tree_truncated,
}
except HTTPException:
raise
@ -5472,78 +5900,98 @@ def _decode_header_str(header_val):
except:
return str(header_val)
def _copy_uploaded_email_attachments_to_case(sag_id: int, email_id: int, attachments: List[Dict]) -> int:
"""Copy valid manual-email attachments into the normal case-files module."""
if not attachments or not _table_exists("sag_files"):
return 0
has_source_email = table_has_column("sag_files", "source_email_id")
has_source_type = table_has_column("sag_files", "source_type")
copied = 0
for attachment in attachments:
filename = Path(str(attachment.get("filename") or "attachment.bin")).name
content = attachment.get("content")
if not filename or not content:
continue
content = bytes(content)
extension = Path(filename).suffix.lower().lstrip(".")
if not extension or extension not in ALLOWED_EXTENSIONS or len(content) > MAX_ATTACHMENT_SIZE:
logger.warning("Skipping unsupported or oversized email attachment %s for SAG-%s", filename, sag_id)
continue
if has_source_email:
existing = execute_query(
"""SELECT 1 FROM sag_files
WHERE sag_id = %s AND source_email_id = %s AND filename = %s
LIMIT 1""",
(sag_id, email_id, filename),
) or []
if existing:
continue
stored_name = _generate_stored_name(filename, SAG_FILE_SUBDIR)
destination = _resolve_attachment_path(stored_name)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(content)
columns = ["sag_id", "filename", "content_type", "size_bytes", "stored_name"]
values: List[Any] = [
sag_id,
filename,
attachment.get("content_type") or "application/octet-stream",
len(content),
stored_name,
]
if has_source_email:
columns.append("source_email_id")
values.append(email_id)
if has_source_type:
columns.append("source_type")
values.append("email_import")
execute_query(
f"INSERT INTO sag_files ({', '.join(columns)}) VALUES ({', '.join(['%s'] * len(values))})",
tuple(values),
)
copied += 1
return copied
@router.post("/sag/{sag_id}/upload-email")
async def upload_sag_email(sag_id: int, file: UploadFile = File(...)):
"""Upload .eml/.msg, parse it, save to email_messages, and link to case."""
content = await file.read()
filename = file.filename.lower()
email_data = {}
temp_id = uuid4().hex
# 1. Parse File
if filename.endswith('.msg'):
if not extract_msg: raise HTTPException(500, "extract-msg missing")
import io
msg = extract_msg.Message(io.BytesIO(content))
email_data = {
'message_id': msg.messageId or f"msg-{temp_id}",
'subject': msg.subject or "No Subject",
'sender_email': msg.sender or "",
'sender_name': msg.sender or "",
'recipient_email': msg.to or "",
'cc': msg.cc or "",
'body_text': msg.body,
'body_html': msg.htmlBody,
'received_date': msg.date or datetime.now(),
'folder': 'Imported',
'attachments': [],
'has_attachments': False,
'attachment_count': 0
}
elif filename.endswith('.eml'):
msg = email.message_from_bytes(content)
body_text = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
body_text = part.get_payload(decode=True).decode('utf-8', errors='ignore')
break
else:
body_text = msg.get_payload(decode=True).decode('utf-8', errors='ignore')
email_data = {
'message_id': msg.get('Message-ID', f"eml-{temp_id}"),
'in_reply_to': _decode_header_str(msg.get('In-Reply-To', '')),
'email_references': _decode_header_str(msg.get('References', '')),
'subject': _decode_header_str(msg.get('Subject', 'No Subject')),
'sender_email': _decode_header_str(msg.get('From', '')),
'sender_name': _decode_header_str(msg.get('From', '')),
'recipient_email': _decode_header_str(msg.get('To', '')),
'cc': _decode_header_str(msg.get('Cc', '')),
'received_date': datetime.now(),
'body_text': body_text,
'body_html': "",
'folder': 'Imported',
'has_attachments': False,
'attachment_count': 0,
'attachments': []
}
else:
"""Import .eml/.msg into the email tab and copy its attachments to case files."""
filename = str(file.filename or "").lower()
if not (filename.endswith(".eml") or filename.endswith(".msg")):
raise HTTPException(400, "Only .msg or .eml files allowed")
# 2. Save Email via Service (deduplicate)
svc = EmailService()
existing = execute_query_single("SELECT id FROM email_messages WHERE message_id = %s", (email_data['message_id'],))
content = await file.read()
service = EmailService()
email_data = service.parse_msg_file(content) if filename.endswith(".msg") else service.parse_eml_file(content)
if not email_data:
raise HTTPException(status_code=400, detail="E-mailfilen kunne ikke læses")
if existing:
email_id = existing['id']
else:
# We try to strict save, keeping it simple
email_id = await svc.save_email(email_data)
email_id = await service.save_uploaded_email(email_data)
if not email_id:
existing = execute_query_single(
"SELECT id FROM email_messages WHERE message_id = %s",
(email_data["message_id"],),
)
if not existing:
raise HTTPException(status_code=500, detail="E-mailen kunne ikke gemmes")
email_id = existing["id"]
# 3. Link
await add_sag_email_link(sag_id, {"email_id": email_id})
return {"status": "imported", "email_id": email_id}
attachment_files_created = _copy_uploaded_email_attachments_to_case(
sag_id,
int(email_id),
email_data.get("attachments") or [],
)
return {
"status": "imported",
"email_id": email_id,
"attachment_files_created": attachment_files_created,
}
@router.post("/sag/{sag_id}/emails/send")
@ -5708,8 +6156,12 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
failure_detail = "Email send failed (provider returned generic error; check Graph app permissions and API logs)"
raise HTTPException(status_code=500, detail=f"{failure_detail} [diag:sag-send-v3]")
sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub"
sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or ""
sender_name = email_service.smtp_config.get("from_name") or "BMC Hub"
sender_email = (
email_service.graph_config.get("user_email")
if email_service._graph_send_available()
else email_service.smtp_config.get("from_address")
) or ""
provider_thread_key_normalized = _normalize_message_id_token(provider_thread_key)
# Keep replies in the existing case thread when we already know the target thread.

View File

@ -159,47 +159,7 @@ def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
def _fetch_case_status_options() -> list[str]:
default_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
values = []
seen = set()
def _add(value: Optional[str]) -> None:
candidate = str(value or "").strip()
if not candidate:
return
key = candidate.lower()
if key in seen:
return
seen.add(key)
values.append(candidate)
setting_row = execute_query(
"SELECT value FROM settings WHERE key = %s",
("case_statuses",)
)
if setting_row and setting_row[0].get("value"):
try:
parsed = json.loads(setting_row[0].get("value") or "[]")
for item in parsed if isinstance(parsed, list) else []:
value = ""
if isinstance(item, str):
value = item.strip()
elif isinstance(item, dict):
value = str(item.get("value") or "").strip()
_add(value)
except Exception:
pass
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
for row in statuses:
_add(row.get("status"))
for default in default_statuses:
_add(default)
return values
return ["Ny", "Tildelt", "Igangværende", "Afventer", "Løst", "Kunde svaret", "Lukket"]
def _fetch_closed_case_statuses() -> list[str]:
@ -571,8 +531,8 @@ async def sag_varekob_salg(request: Request):
@router.get("/sag/{sag_id}", response_class=HTMLResponse)
async def sag_detaljer(request: Request, sag_id: int):
"""Redirect legacy case details URL to v3."""
return RedirectResponse(url=f"/sag/{sag_id}/v3", status_code=307)
"""Redirect the unversioned case URL to the active V4 page."""
return RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)
"""Display case details."""
try:
@ -999,9 +959,9 @@ async def sag_detaljer(request: Request, sag_id: int):
raise HTTPException(status_code=500, detail="Failed to load case details")
@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse)
async def sag_detaljer_v3(request: Request, sag_id: int):
"""Display case details."""
@router.get("/sag/{sag_id}/v4", response_class=HTMLResponse)
async def sag_detaljer_v4(request: Request, sag_id: int):
"""Display the active V4 case page."""
try:
# Fetch main case
sag_query = """
@ -1339,7 +1299,8 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
status_options.append(current_status)
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
response = templates.TemplateResponse("modules/sag/templates/detail_v3.html", {
template_name = "modules/sag/templates/detail_v4.html"
response = templates.TemplateResponse(template_name, {
"request": request,
"case": sag,
"customer": customer,
@ -1374,6 +1335,12 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
raise HTTPException(status_code=500, detail="Failed to load case details")
@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse)
async def sag_detaljer_v3_legacy(sag_id: int):
"""Keep old links working, while making V4 the canonical case URL."""
return RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)
@router.get("/sag/{sag_id}/edit", response_class=HTMLResponse)
async def sag_rediger(request: Request, sag_id: int):
"""Display edit case form."""

View File

@ -45,9 +45,15 @@ class RelationService:
s.status,
s.template_key,
COALESCE(s.template_key, 'ticket') AS type,
s.ansvarlig_bruger_id,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
s.assigned_group_id,
g.name AS gruppe_navn,
next_todo.title AS next_todo_title,
next_todo.due_date AS next_todo_due_date
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT t.title, t.due_date
FROM sag_todo_steps t

View File

@ -245,7 +245,10 @@
.cc-template-toolbar .form-select { min-height:36px; max-width:280px; font-size:.78rem; }
.cc-page .cc-type-section { padding:0 0 16px; margin-bottom:16px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
.cc-page .cc-type-section > label { display:block; margin-bottom:8px !important; text-transform:none; letter-spacing:0; color:var(--text-primary,#213547); font-weight:600; font-size:.82rem; }
.cc-page #caseTypeHelp { display:none; }
.cc-page #caseTypeHelp { display:block; }
.cc-page .card-custom.cc-postit-mode { background:#fff5a8; border:1px solid #e6c74d; box-shadow:8px 10px 22px rgba(112,87,8,.2); }
.cc-page .cc-postit-panel { background:#fffbe0; border-color:#dfbd37; }
.cc-page .cc-postit-panel summary { background:#fff1a8; }
.cc-page #cc-types { gap:7px; }
.cc-page #cc-types > button { flex:0 1 auto; min-height:0; padding:8px 11px; border-radius:9px; font-size:.78rem; }
.cc-page .cc-type-name { display:inline; font-size:.78rem; }
@ -286,11 +289,6 @@
<div class="col-12">
<!-- Main Card -->
<div class="card card-custom">
<div class="card-header-custom">
<div><span class="cc-eyebrow">ET GODT STED AT STARTE</span><h1>Opret en ny sag</h1><p class="mb-0">Saml opgaven, vælg de rette personer, og tilføj det, du har brug for.</p></div>
<span class="cc-hero-icon" aria-hidden="true"><i class="bi bi-folder-plus"></i></span>
</div>
<div class="card-body p-4">
<div id="caseTopAlerts" class="case-top-alerts d-none mb-3"></div>
@ -307,10 +305,6 @@
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
<select class="form-select form-select-lg" id="type" required></select>
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
<button type="button" id="caseMessageTypeButton" class="btn btn-outline-primary cc-message-type cc-message-fallback" onclick="window.caseCreateUI?.openMessage?.() || window.openInternalMessage?.()">
<span class="cc-type-name"><i class="bi bi-chat-square-text"></i>Besked til kollega</span>
<small class="cc-type-hint">Kort besked eller telefonbesked</small>
</button>
</div>
<!-- Section: Relations -->
@ -346,6 +340,27 @@
</div>
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
<div class="row g-4 mb-4">
<div class="col-md-6">
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
<select class="form-select" id="ansvarlig_bruger_id">
<option value="">Ingen</option>
{% for user in assignment_users or [] %}
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
<select class="form-select" id="assigned_group_id">
<option value="">Ingen</option>
{% for group in assignment_groups or [] %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endfor %}
</select>
</div>
</div>
<hr class="my-4 opacity-25">
<!-- Section: Basic Info -->
@ -365,7 +380,7 @@
<i class="bi bi-magic me-1"></i>AI renskriv
</button>
</div>
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..." required></textarea>
<div class="d-flex justify-content-between form-text">
<span>AI retter kun sprog og foreslår en titel ud fra din tekst. Den må ikke tilføje oplysninger.</span>
<span id="charCount">0 tegn</span>
@ -375,6 +390,11 @@
<hr class="my-4 opacity-25">
<!-- Fields that belong to the selected case type are rendered here. -->
<section id="typeDetailsSection" class="d-none"><div id="typeDetailsFields"></div></section>
<div id="typeModuleAction" class="d-flex flex-wrap gap-2 mt-4 mb-4 d-none" aria-label="Tilføj moduler"></div>
<!-- Section: Hardware & AnyDesk -->
<section id="hardwareSection">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
@ -436,10 +456,12 @@
<hr class="my-4 opacity-25">
<!-- Section: Metadata -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
<!-- Optional metadata stays available without competing with the core flow. -->
<details id="advancedCaseFields" class="mt-4">
<summary class="small text-muted fw-semibold">Flere valg: ansvarlig, status og deadline</summary>
<div class="pt-3">
<div class="row g-4 mb-4">
<div class="col-md-4">
<div class="col-md-4 case-type-extra">
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
<select class="form-select" id="status" required>
<option value="åben" selected>🟢 Åben</option>
@ -447,28 +469,9 @@
<option value="lukket">🔴 Lukket</option>
</select>
</div>
<div class="col-md-4">
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
<select class="form-select" id="ansvarlig_bruger_id">
<option value="">Ingen</option>
{% for user in assignment_users or [] %}
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
<select class="form-select" id="assigned_group_id">
<option value="">Ingen</option>
{% for group in assignment_groups or [] %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row g-4 mb-4">
<div class="row g-4 mb-4 case-type-extra">
<div class="col-md-4">
<label for="deadline" class="form-label">Deadline</label>
<div class="input-group">
@ -477,6 +480,8 @@
</div>
</div>
</div>
</div>
</details>
<!-- Action Buttons -->
<div class="d-flex justify-content-end gap-3 mt-5">
@ -1329,20 +1334,22 @@
}
const caseTypeLabels = {
ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave',
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service',
abonnement: '🔁 Abonnement'
ticket: '🎫 Ticket', salg: '💼 Salg', bogholderi: '🧾 Bogholderi',
admin: '⚙️ Admin', projekt: '📁 Projekt', postit: '🗒️ Post-it',
intern_ticket: '🔒 Intern ticket', bug_report: '🐞 Bug_report',
pipeline: '📈 Pipeline', opgave: '🧩 Opgave', ordre: '🧾 Ordre',
service: '🛠️ Service', abonnement: '🔁 Abonnement'
};
function updateCaseTypeSections() {
function updateCaseTypeSections(showTypeFields = false) {
const type = document.getElementById('type')?.value || 'ticket';
['hardwareSection', 'pipelineSection', 'orderSection'].forEach(id => document.getElementById(id)?.classList.remove('d-none'));
window.caseCreateUI?.typeChanged(type);
window.caseCreateUI?.typeChanged(type, showTypeFields);
const help = document.getElementById('caseTypeHelp');
if (help) help.textContent = type === 'ticket' ? 'Tilføj eventuelt hardware og AnyDesk til din ticket. Alle ekstra funktioner er tilgængelige.'
if (help && !showTypeFields) help.textContent = 'Vælg sagstype for at åbne de relevante felter.';
else if (help) help.textContent = type === 'ticket' ? 'Tilføj eventuelt hardware og AnyDesk til din ticket.'
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
: 'Tilføj hardware, pipeline, tags og køb/salg efter behov.';
: 'Tilføj de relevante oplysninger til sagen.';
}
function renderOrderLinesEmptyState() {
@ -1417,28 +1424,29 @@
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.length ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : Object.keys(caseTypeLabels);
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
if (!types.includes('abonnement')) types.push('abonnement');
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
const configuredTypes = Array.isArray(configured) ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : [];
const supportedTypes = ['ticket', 'postit', 'salg', 'bogholderi', 'admin', 'projekt', 'intern_ticket', 'bug_report'];
const finalTypes = supportedTypes;
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).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]) => `<option value="${type}">${label}</option>`).join('');
}
updateCaseTypeSections();
updateCaseTypeSections(false);
}
async function selectCurrentUserAsResponsible() {
const select = document.getElementById('ansvarlig_bruger_id');
if (!select || select.value) return;
if (!select) return;
try {
const response = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!response.ok) return;
const user = await response.json();
const userId = user.user_id ?? user.id;
if (userId && Array.from(select.options).some(option => option.value === String(userId))) {
window.caseCreateCurrentUserId = userId || null;
const isInternalTicket = document.getElementById('type')?.value === 'intern_ticket';
if (userId && (isInternalTicket || !select.value) && Array.from(select.options).some(option => option.value === String(userId))) {
select.value = String(userId);
}
} catch (error) {
@ -1450,7 +1458,10 @@
document.addEventListener('DOMContentLoaded', async () => {
initializeSearch();
await Promise.all([loadCaseTypesSelect(), loadPipelineStages(), selectCurrentUserAsResponsible()]);
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
// Both the selected type and current user are now known. Reapply the
// type defaults so Intern ticket cannot display a stale assignee.
updateCaseTypeSections(false);
document.getElementById('type')?.addEventListener('change', () => updateCaseTypeSections(false));
await applyTelefoniPrefill();
await window.caseCreateUI?.init();
});
@ -1566,7 +1577,7 @@
successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = "Sag oprettet";
window.location.href = `/sag/${result.id}/v3`;
window.location.href = `/sag/${result.id}/v4`;
} else {
const errorText = await response.text();
let errMsg = "Kunne ikke oprette sag";
@ -1586,5 +1597,5 @@
}
});
</script>
<script src="/static/js/case-create.js?v=5"></script>
<script src="/static/js/case-create.js?v=13"></script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -148,7 +148,7 @@ class TelefoniService:
pending_rows = execute_query(
"""
SELECT * FROM telefoni_opkald
WHERE callid LIKE 'click-to-call:%'
WHERE callid LIKE 'click-to-call:%%'
AND direction = 'outbound'
AND bruger_id = %s
AND ended_at IS NULL

View File

@ -34,8 +34,9 @@ from aiohttp import ClientSession, BasicAuth
import msal
from app.core.config import settings
from app.core.crypto import decrypt_secret
from app.services.system_event_logger import log_system_event
from app.core.database import execute_query, execute_insert, execute_update
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
logger = logging.getLogger(__name__)
@ -60,6 +61,78 @@ class EmailService:
'client_secret': settings.GRAPH_CLIENT_SECRET,
'user_email': settings.GRAPH_USER_EMAIL
}
self.smtp_config = {
'host': settings.EMAIL_SMTP_HOST,
'port': settings.EMAIL_SMTP_PORT,
'username': settings.EMAIL_SMTP_USER,
'password': settings.EMAIL_SMTP_PASSWORD,
'use_tls': settings.EMAIL_SMTP_USE_TLS,
'from_address': settings.EMAIL_SMTP_FROM_ADDRESS,
'from_name': settings.EMAIL_SMTP_FROM_NAME,
}
self._apply_saved_connection()
def _apply_saved_connection(self) -> None:
"""Use the one Settings mailbox when enabled, otherwise keep environment compatibility."""
try:
row = execute_query_single(
"SELECT * FROM email_connection_configurations WHERE is_enabled = true ORDER BY id DESC LIMIT 1"
)
except Exception as exc:
# Older installations can run before migration 1035. Their .env setup remains authoritative.
logger.debug("Email connection settings unavailable; using environment configuration: %s", exc)
return
if not row:
return
raw_secrets = decrypt_secret(str(row.get('secrets_encrypted') or ''))
if not raw_secrets:
logger.error("Enabled email connection has no readable credentials; using environment configuration")
return
try:
saved_secrets = json.loads(raw_secrets)
except (TypeError, json.JSONDecodeError):
logger.error("Enabled email connection has invalid encrypted credentials; using environment configuration")
return
mailbox = str(row.get('mailbox_address') or '').strip()
if row.get('provider') == 'microsoft365_graph':
required = ('graph_tenant_id', 'graph_client_id', 'graph_client_secret')
if not all((row.get('graph_tenant_id'), row.get('graph_client_id'), saved_secrets.get('graph_client_secret'))):
logger.error("Enabled Microsoft 365 email connection is incomplete; using environment configuration")
return
self.use_graph = True
self.graph_config = {
'tenant_id': row.get('graph_tenant_id'),
'client_id': row.get('graph_client_id'),
'client_secret': saved_secrets['graph_client_secret'],
'user_email': mailbox,
}
return
if not all((row.get('imap_host'), row.get('smtp_host'), saved_secrets.get('imap_password'), saved_secrets.get('smtp_password'))):
logger.error("Enabled IMAP/SMTP email connection is incomplete; using environment configuration")
return
self.use_graph = False
self.imap_config = {
'server': row.get('imap_host'),
'port': row.get('imap_port') or 993,
'username': mailbox,
'password': saved_secrets['imap_password'],
'use_ssl': bool(row.get('imap_use_ssl')),
'folder': row.get('imap_folder') or 'INBOX',
'readonly': True,
}
# Outbound SMTP is read by the sender further down via settings. Keep config local as well.
self.smtp_config = {
'host': row.get('smtp_host'),
'port': row.get('smtp_port') or 587,
'username': mailbox,
'password': saved_secrets['smtp_password'],
'use_tls': bool(row.get('smtp_use_tls')),
'from_address': mailbox,
'from_name': row.get('sender_name') or 'BMC Hub',
}
def _graph_send_available(self) -> bool:
return bool(
@ -1789,7 +1862,7 @@ class EmailService:
return False, "aiosmtplib not installed"
# Validate SMTP configuration
if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]):
if not all([self.smtp_config['host'], self.smtp_config['username'], self.smtp_config['password']]):
logger.error("❌ SMTP not configured - cannot send email")
if graph_failure_message:
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured"
@ -1799,7 +1872,7 @@ class EmailService:
# Build message
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f"{settings.EMAIL_SMTP_FROM_NAME} <{settings.EMAIL_SMTP_FROM_ADDRESS}>"
msg['From'] = f"{self.smtp_config['from_name']} <{self.smtp_config['from_address']}>"
msg['To'] = ', '.join(to_addresses)
if cc:
@ -1817,11 +1890,11 @@ class EmailService:
# Send via SMTP
async with aiosmtplib.SMTP(
hostname=settings.EMAIL_SMTP_HOST,
port=settings.EMAIL_SMTP_PORT,
use_tls=settings.EMAIL_SMTP_USE_TLS
hostname=self.smtp_config['host'],
port=self.smtp_config['port'],
use_tls=self.smtp_config['use_tls']
) as smtp:
await smtp.login(settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD)
await smtp.login(self.smtp_config['username'], self.smtp_config['password'])
# Combine all recipients
all_recipients = to_addresses.copy()
@ -1831,7 +1904,7 @@ class EmailService:
all_recipients.extend(bcc)
await smtp.sendmail(
settings.EMAIL_SMTP_FROM_ADDRESS,
self.smtp_config['from_address'],
all_recipients,
msg.as_string()
)
@ -1928,7 +2001,7 @@ class EmailService:
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: aiosmtplib not installed", generated_message_id, provider_thread_key
return False, "aiosmtplib not installed", generated_message_id, provider_thread_key
if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]):
if not all([self.smtp_config['host'], self.smtp_config['username'], self.smtp_config['password']]):
logger.error("❌ SMTP not configured - cannot send email")
if graph_failure_message:
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured", generated_message_id, provider_thread_key
@ -1937,7 +2010,7 @@ class EmailService:
try:
msg = MIMEMultipart('mixed')
msg['Subject'] = subject
msg['From'] = f"{settings.EMAIL_SMTP_FROM_NAME} <{settings.EMAIL_SMTP_FROM_ADDRESS}>"
msg['From'] = f"{self.smtp_config['from_name']} <{self.smtp_config['from_address']}>"
msg['To'] = ', '.join(to_addresses)
msg['Message-ID'] = generated_message_id
@ -1974,11 +2047,11 @@ class EmailService:
msg.attach(mime_attachment)
async with aiosmtplib.SMTP(
hostname=settings.EMAIL_SMTP_HOST,
port=settings.EMAIL_SMTP_PORT,
use_tls=settings.EMAIL_SMTP_USE_TLS
hostname=self.smtp_config['host'],
port=self.smtp_config['port'],
use_tls=self.smtp_config['use_tls']
) as smtp:
await smtp.login(settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD)
await smtp.login(self.smtp_config['username'], self.smtp_config['password'])
all_recipients = to_addresses.copy()
if cc:
@ -1987,7 +2060,7 @@ class EmailService:
all_recipients.extend(bcc)
await smtp.sendmail(
settings.EMAIL_SMTP_FROM_ADDRESS,
self.smtp_config['from_address'],
all_recipients,
msg.as_string()
)

View File

@ -166,6 +166,58 @@ class VTigerService:
logger.error(f"❌ vTiger query error: {e}")
return []
async def describe_module(self, module_name: str) -> Dict[str, Any]:
"""Return vTiger module metadata (field names, editability and requirements)."""
module = str(module_name or "").strip()
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", module):
raise ValueError("Ugyldigt vTiger-modul")
if not self.rest_endpoint:
raise ValueError("VTIGER_URL not configured")
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.rest_endpoint}/describe",
params={"elementType": module},
auth=self._get_auth(),
timeout=aiohttp.ClientTimeout(total=30),
) as response:
text = await response.text()
try:
payload = json.loads(text)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Ugyldigt svar fra vTiger describe ({response.status})") from exc
if response.status != 200 or not payload.get("success"):
error = payload.get("error", {}).get("message") or f"HTTP {response.status}"
raise RuntimeError(f"vTiger kunne ikke hente modulbeskrivelse: {error}")
return payload.get("result") or {}
async def create_record(self, module_name: str, fields: Dict[str, Any]) -> Dict[str, Any]:
"""Create one record using vTiger's REST API create contract."""
module = str(module_name or "").strip()
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", module):
raise ValueError("Ugyldigt vTiger-modul")
if not isinstance(fields, dict) or not fields:
raise ValueError("Tom vTiger-post")
if not self.rest_endpoint:
raise ValueError("VTIGER_URL not configured")
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.rest_endpoint}/create",
params={"elementType": module, "element": json.dumps(fields, ensure_ascii=False)},
auth=self._get_auth(),
timeout=aiohttp.ClientTimeout(total=30),
) as response:
text = await response.text()
try:
payload = json.loads(text)
except json.JSONDecodeError as exc:
excerpt = re.sub(r"\s+", " ", text).strip()[:300]
suffix = f": {excerpt}" if excerpt else " (tomt svar)"
raise RuntimeError(f"Ugyldigt svar fra vTiger ({response.status}){suffix}") from exc
if response.status != 200 or not payload.get("success"):
error = payload.get("error", {}).get("message") or f"HTTP {response.status}"
raise RuntimeError(f"vTiger kunne ikke oprette posten: {error}")
return payload.get("result") or {}
async def retrieve_file(self, resource_id: str) -> Optional[Dict[str, Any]]:
"""Retrieve the original bytes for a vTiger file resource."""
safe_id = self._sanitize_vtiger_id(resource_id)
@ -606,6 +658,49 @@ class VTigerService:
logger.error(f"❌ Error updating subscription: {e}")
raise
async def update_record_fields(self, module_name: str, record_id: str, updates: Dict[str, Any]) -> Dict[str, Any]:
"""Update a small allow-listed field set on one vTiger record.
This generic variant is used for explicitly requested single-record
migrations. Callers must validate both the module and field names.
"""
module = str(module_name or "").strip()
safe_id = self._sanitize_vtiger_id(record_id)
if not module or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", module):
raise ValueError("Ugyldigt vTiger-modul")
if not safe_id or not isinstance(updates, dict) or not updates:
raise ValueError("Ugyldigt vTiger-id eller tom ændring")
if not self.rest_endpoint:
raise ValueError("VTIGER_URL not configured")
current_rows = await self.query(f"SELECT * FROM {module} WHERE id='{safe_id}' LIMIT 1;")
if not current_rows:
raise ValueError(f"vTiger-posten {safe_id} blev ikke fundet")
# The Vtiger REST API's revise method is intended for partial changes:
# POST /revise?element=<JSON string>. The update endpoint has a
# different contract and may return an empty HTTP 400 when called with
# the wrapped {elementType, element} payload used by other adapters.
element = {"id": safe_id, **updates}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.rest_endpoint}/revise",
params={"element": json.dumps(element)},
auth=self._get_auth(),
timeout=aiohttp.ClientTimeout(total=30),
) as response:
text = await response.text()
try:
result = json.loads(text)
except json.JSONDecodeError as exc:
excerpt = re.sub(r"\s+", " ", text).strip()[:300]
suffix = f": {excerpt}" if excerpt else " (tomt svar)"
raise RuntimeError(f"Ugyldigt svar fra vTiger ({response.status}){suffix}") from exc
if response.status != 200 or not result.get("success"):
error = result.get("error", {}).get("message") or f"HTTP {response.status}"
raise RuntimeError(f"vTiger kunne ikke opdatere posten: {error}")
return result.get("result") or {}
async def get_service_contracts(self, account_id: Optional[str] = None, active_only: bool = True) -> List[Dict]:
"""
Fetch service contracts from vTiger

View File

@ -3,16 +3,18 @@ Settings and User Management API Router
"""
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends, Query
from typing import List, Optional, Dict
from typing import List, Optional, Dict, Literal
from pydantic import BaseModel
from datetime import datetime, timezone
from app.core.database import execute_query, execute_query_single
from app.core.config import settings
from app.core.auth_dependencies import require_any_permission, require_superadmin
from app.core.auth_service import AuthService
from app.core.crypto import decrypt_secret, encrypt_secret
import argparse
import asyncio
import httpx
import imaplib
import time
import logging
import json
@ -20,6 +22,7 @@ import os
import re
import threading
from pathlib import Path
from urllib.parse import quote
logger = logging.getLogger(__name__)
router = APIRouter()
@ -75,6 +78,52 @@ class AIBenchmarkRunRequest(BaseModel):
test_keys: Optional[List[str]] = None
class EmailConnectionUpdate(BaseModel):
provider: Literal["microsoft365_graph", "imap_smtp"]
is_enabled: bool = False
mailbox_address: str
sender_name: str = "BMC Hub"
imap_host: Optional[str] = ""
imap_port: int = 993
imap_folder: str = "INBOX"
imap_use_ssl: bool = True
smtp_host: Optional[str] = ""
smtp_port: int = 587
smtp_use_tls: bool = True
graph_tenant_id: Optional[str] = ""
graph_client_id: Optional[str] = ""
imap_password: Optional[str] = None
smtp_password: Optional[str] = None
graph_client_secret: Optional[str] = None
def _email_connection_public(row: Optional[Dict]) -> Dict:
"""Return connection metadata only; encrypted credentials never leave the server."""
if not row:
return {"configured": False, "source": "environment"}
encrypted = str(row.get("secrets_encrypted") or "").strip()
return {
"configured": True,
"source": "settings",
"provider": row["provider"],
"is_enabled": bool(row["is_enabled"]),
"mailbox_address": row["mailbox_address"],
"sender_name": row.get("sender_name") or "BMC Hub",
"imap_host": row.get("imap_host") or "",
"imap_port": row.get("imap_port") or 993,
"imap_folder": row.get("imap_folder") or "INBOX",
"imap_use_ssl": bool(row.get("imap_use_ssl")),
"smtp_host": row.get("smtp_host") or "",
"smtp_port": row.get("smtp_port") or 587,
"smtp_use_tls": bool(row.get("smtp_use_tls")),
"graph_tenant_id": row.get("graph_tenant_id") or "",
"graph_client_id": row.get("graph_client_id") or "",
"has_saved_credentials": bool(encrypted),
"inbound_processing_enabled": bool(settings.EMAIL_TO_TICKET_ENABLED),
"updated_at": row.get("updated_at"),
}
MATTERMOST_SETTING_DEFAULTS = (
("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"),
("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"),
@ -273,6 +322,177 @@ async def create_ai_benchmark_run(
# Settings Endpoints
@router.get("/settings/email-connection", tags=["Settings"])
async def get_email_connection(current_user: dict = Depends(require_superadmin)):
"""Read mailbox setup metadata without exposing any password or client secret."""
row = execute_query_single(
"SELECT * FROM email_connection_configurations ORDER BY id DESC LIMIT 1"
)
if row:
return _email_connection_public(row)
return {
"configured": False,
"source": "environment",
"provider": "microsoft365_graph" if settings.USE_GRAPH_API else "imap_smtp",
"is_enabled": bool(
settings.GRAPH_USER_EMAIL if settings.USE_GRAPH_API else settings.IMAP_USERNAME
),
"mailbox_address": settings.GRAPH_USER_EMAIL if settings.USE_GRAPH_API else settings.IMAP_USERNAME,
"sender_name": settings.EMAIL_SMTP_FROM_NAME or "BMC Hub",
"has_saved_credentials": bool(
settings.GRAPH_CLIENT_SECRET if settings.USE_GRAPH_API else settings.IMAP_PASSWORD
),
"inbound_processing_enabled": bool(settings.EMAIL_TO_TICKET_ENABLED),
}
@router.put("/settings/email-connection", tags=["Settings"])
async def save_email_connection(
payload: EmailConnectionUpdate,
current_user: dict = Depends(require_superadmin),
):
"""Save one encrypted mailbox configuration used for both inbound and outbound mail."""
mailbox = payload.mailbox_address.strip().lower()
if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", mailbox):
raise HTTPException(status_code=400, detail="Indtast en gyldig postkasseadresse")
if not (1 <= payload.imap_port <= 65535 and 1 <= payload.smtp_port <= 65535):
raise HTTPException(status_code=400, detail="Port skal være mellem 1 og 65535")
existing = execute_query_single(
"SELECT * FROM email_connection_configurations ORDER BY id DESC LIMIT 1"
)
prior_secrets = (existing or {}).get("secrets_encrypted")
saved_secrets = {}
if prior_secrets:
decoded = decrypt_secret(str(prior_secrets))
if decoded:
try:
saved_secrets = json.loads(decoded)
except (TypeError, json.JSONDecodeError):
raise HTTPException(status_code=500, detail="De gemte e-mailadgangsoplysninger kan ikke læses. Indtast dem igen.")
supplied_secrets = {
key: value.strip()
for key, value in {
"imap_password": payload.imap_password,
"smtp_password": payload.smtp_password,
"graph_client_secret": payload.graph_client_secret,
}.items()
if value is not None and value.strip()
}
saved_secrets.update(supplied_secrets)
secrets_encrypted = prior_secrets
if supplied_secrets or (prior_secrets and not saved_secrets):
try:
secrets_encrypted = encrypt_secret(json.dumps(saved_secrets))
except ValueError:
raise HTTPException(
status_code=503,
detail="Sikker lagring er ikke klar. Sæt NEXTCLOUD_ENCRYPTION_KEY på serveren før adgangsoplysninger gemmes.",
)
if payload.provider == "microsoft365_graph":
if payload.is_enabled and not (payload.graph_tenant_id.strip() and payload.graph_client_id.strip() and saved_secrets.get("graph_client_secret")):
raise HTTPException(status_code=400, detail="Microsoft 365 kræver Tenant ID, Application ID og client secret før aktivering")
elif payload.is_enabled and not (
payload.imap_host.strip() and payload.smtp_host.strip()
and saved_secrets.get("imap_password") and saved_secrets.get("smtp_password")
):
raise HTTPException(status_code=400, detail="IMAP/SMTP kræver begge servere og adgangskoder før aktivering")
user_id = current_user.get("id") or current_user.get("user_id")
values = (
payload.provider, payload.is_enabled, mailbox, payload.sender_name.strip() or "BMC Hub",
payload.imap_host.strip() or None, payload.imap_port, payload.imap_folder.strip() or "INBOX", payload.imap_use_ssl,
payload.smtp_host.strip() or None, payload.smtp_port, payload.smtp_use_tls,
payload.graph_tenant_id.strip() or None, payload.graph_client_id.strip() or None,
secrets_encrypted, user_id,
)
if existing:
query = """
UPDATE email_connection_configurations SET
provider=%s, is_enabled=%s, mailbox_address=%s, sender_name=%s,
imap_host=%s, imap_port=%s, imap_folder=%s, imap_use_ssl=%s,
smtp_host=%s, smtp_port=%s, smtp_use_tls=%s,
graph_tenant_id=%s, graph_client_id=%s, secrets_encrypted=%s,
updated_by_user_id=%s, updated_at=CURRENT_TIMESTAMP
WHERE id=%s RETURNING *
"""
result = execute_query(query, (*values, existing["id"]))
else:
query = """
INSERT INTO email_connection_configurations
(provider, is_enabled, mailbox_address, sender_name, imap_host, imap_port, imap_folder, imap_use_ssl,
smtp_host, smtp_port, smtp_use_tls, graph_tenant_id, graph_client_id, secrets_encrypted, updated_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *
"""
result = execute_query(query, values)
if not result:
raise HTTPException(status_code=500, detail="Kunne ikke gemme e-mailopsætningen")
return _email_connection_public(result[0])
@router.post("/settings/email-connection/test", tags=["Settings"])
async def test_email_connection(current_user: dict = Depends(require_superadmin)):
"""Verify authentication and inbox access without sending or changing any email."""
from app.services.email_service import EmailService, HAS_AIOSMTPLIB, aiosmtplib
configured = execute_query_single(
"SELECT provider, is_enabled FROM email_connection_configurations ORDER BY id DESC LIMIT 1"
)
if not configured or not configured.get("is_enabled"):
raise HTTPException(status_code=400, detail="Gem og aktivér en e-mailopsætning før du tester")
service = EmailService()
if configured["provider"] == "microsoft365_graph":
token = await service._get_graph_access_token()
if not token:
raise HTTPException(status_code=502, detail="Microsoft 365 kunne ikke udstede et adgangstoken. Tjek Tenant ID, Application ID og client secret.")
mailbox = service.graph_config.get("user_email")
try:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.get(
f"https://graph.microsoft.com/v1.0/users/{quote(mailbox)}/mailFolders/inbox?$top=1",
headers={"Authorization": f"Bearer {token}"},
)
if response.status_code >= 400:
raise HTTPException(status_code=502, detail="Microsoft 365-token virker, men postkassen kan ikke læses. Tjek Mail.ReadWrite og admin consent.")
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Kunne ikke kontakte Microsoft Graph: {exc}")
return {"ok": True, "provider": "Microsoft 365 / Graph", "message": "Token og læseadgang til Indbakke virker. Der er ikke sendt en e-mail."}
def test_imap() -> None:
config = service.imap_config
connection = imaplib.IMAP4_SSL(config["server"], config["port"], timeout=15) if config["use_ssl"] else imaplib.IMAP4(config["server"], config["port"], timeout=15)
try:
connection.login(config["username"], config["password"])
status, _ = connection.select(config["folder"], readonly=True)
if status != "OK":
raise RuntimeError(f"kunne ikke åbne mappen {config['folder']}")
finally:
try:
connection.logout()
except Exception:
pass
try:
await asyncio.to_thread(test_imap)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"IMAP-test fejlede: {exc}")
if not HAS_AIOSMTPLIB:
raise HTTPException(status_code=503, detail="IMAP virker, men SMTP-test er ikke mulig fordi aiosmtplib mangler på serveren")
try:
config = service.smtp_config
async with aiosmtplib.SMTP(
hostname=config["host"], port=config["port"], use_tls=config["use_tls"], timeout=15
) as smtp:
await smtp.login(config["username"], config["password"])
except Exception as exc:
raise HTTPException(status_code=502, detail=f"SMTP-test fejlede: {exc}")
return {"ok": True, "provider": "IMAP + SMTP", "message": "IMAP-indbakke og SMTP-login virker. Der er ikke sendt en e-mail."}
@router.get("/settings", response_model=List[Setting], tags=["Settings"])
async def get_settings(category: Optional[str] = None):
"""Get all settings or filter by category"""
@ -375,7 +595,7 @@ async def get_setting(key: str):
seed_query,
(
"case_types",
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service", "abonnement"]',
'["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"]',
"system",
"Sags-typer",
"json",

View File

@ -171,6 +171,9 @@
<a class="nav-link" href="#notifications" data-tab="notifications">
<i class="bi bi-bell me-2"></i>Notifikationer
</a>
<a class="nav-link" href="#email" data-tab="email">
<i class="bi bi-envelope-at me-2"></i>E-mail
</a>
<a class="nav-link" href="#users" data-tab="users">
<i class="bi bi-people me-2"></i>Brugere
</a>
@ -604,6 +607,84 @@
</div>
</div>
<!-- Email connection -->
<div class="tab-pane fade" id="email">
<div class="card p-4 mb-4 border-0 shadow-sm">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<h5 class="fw-bold mb-1">Postkasse til sager</h5>
<p class="text-muted mb-0">Den valgte postkasse bruges både til at hente indgående mails og sende svar fra BMC Hub.</p>
</div>
<span class="badge text-bg-secondary" id="emailConnectionState">Indlæser…</span>
</div>
<div class="alert alert-info small mt-4 mb-0">
<i class="bi bi-shield-lock me-2"></i>Adgangskoder og client secrets krypteres på serveren og vises aldrig igen. Lad et hemmeligt felt stå tomt for at beholde den gemte værdi.
</div>
<div class="alert alert-warning small mt-3 mb-0 d-none" id="emailInboundDisabled">
<i class="bi bi-exclamation-triangle me-2"></i>Automatisk hentning af indgående mails er slået fra på serveren. Opsætningen kan gemmes, men <code>EMAIL_TO_TICKET_ENABLED=true</code> skal aktiveres ved deployment, før nye mails hentes automatisk.
</div>
</div>
<div class="card p-4 mb-4">
<div class="row g-3">
<div class="col-md-5">
<label class="form-label" for="emailProvider">Udbyder</label>
<select class="form-select" id="emailProvider" onchange="toggleEmailProviderFields()">
<option value="microsoft365_graph">Microsoft 365 / Office 365 (Graph – anbefalet)</option>
<option value="imap_smtp">IMAP + SMTP (Google, webhotel eller anden udbyder)</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="emailMailboxAddress">Postkasseadresse</label>
<input type="email" class="form-control" id="emailMailboxAddress" placeholder="support@firma.dk" autocomplete="email">
</div>
<div class="col-md-3">
<label class="form-label" for="emailSenderName">Afsendernavn</label>
<input type="text" class="form-control" id="emailSenderName" placeholder="BMC Support">
</div>
</div>
<div class="form-check form-switch mt-3">
<input class="form-check-input" type="checkbox" id="emailConnectionEnabled">
<label class="form-check-label" for="emailConnectionEnabled">Brug denne postkasse nu</label>
</div>
</div>
<div class="card p-4 mb-4" id="emailGraphFields">
<h6 class="fw-bold mb-1">Microsoft 365 via Microsoft Graph</h6>
<p class="small text-muted mb-3">Robust løsning til en delt postkasse eller en rigtig support-postkasse. BMC Hub bruger den samme adresse ind og ud.</p>
<div class="row g-3">
<div class="col-md-6"><label class="form-label" for="emailGraphTenantId">Directory (tenant) ID</label><input class="form-control" id="emailGraphTenantId" autocomplete="off"></div>
<div class="col-md-6"><label class="form-label" for="emailGraphClientId">Application (client) ID</label><input class="form-control" id="emailGraphClientId" autocomplete="off"></div>
<div class="col-md-6"><label class="form-label" for="emailGraphClientSecret">Client secret</label><input type="password" class="form-control" id="emailGraphClientSecret" autocomplete="new-password"><div class="form-text">Vises kun ved indtastning; en gemt secret kan ikke læses ud igen.</div></div>
</div>
</div>
<div class="card p-4 mb-4 d-none" id="emailImapFields">
<h6 class="fw-bold mb-3">IMAP til modtagelse og SMTP til afsendelse</h6>
<div class="row g-3">
<div class="col-md-5"><label class="form-label" for="emailImapHost">IMAP-server</label><input class="form-control" id="emailImapHost" placeholder="imap.firma.dk"></div>
<div class="col-md-2"><label class="form-label" for="emailImapPort">Port</label><input type="number" min="1" max="65535" class="form-control" id="emailImapPort" value="993"></div>
<div class="col-md-3"><label class="form-label" for="emailImapFolder">Mappe</label><input class="form-control" id="emailImapFolder" value="INBOX"></div>
<div class="col-md-2 d-flex align-items-end"><div class="form-check mb-2"><input class="form-check-input" type="checkbox" id="emailImapSsl" checked><label class="form-check-label" for="emailImapSsl">SSL/TLS</label></div></div>
<div class="col-md-6"><label class="form-label" for="emailImapPassword">IMAP-adgangskode / app-adgangskode</label><input type="password" class="form-control" id="emailImapPassword" autocomplete="new-password"></div>
<div class="col-md-5"><label class="form-label" for="emailSmtpHost">SMTP-server</label><input class="form-control" id="emailSmtpHost" placeholder="smtp.firma.dk"></div>
<div class="col-md-2"><label class="form-label" for="emailSmtpPort">Port</label><input type="number" min="1" max="65535" class="form-control" id="emailSmtpPort" value="587"></div>
<div class="col-md-3 d-flex align-items-end"><div class="form-check mb-2"><input class="form-check-input" type="checkbox" id="emailSmtpTls" checked><label class="form-check-label" for="emailSmtpTls">STARTTLS/TLS</label></div></div>
<div class="col-md-6"><label class="form-label" for="emailSmtpPassword">SMTP-adgangskode / app-adgangskode</label><input type="password" class="form-control" id="emailSmtpPassword" autocomplete="new-password"></div>
</div>
</div>
<div class="d-flex align-items-center gap-3 mb-5 flex-wrap"><button class="btn btn-primary" type="button" onclick="saveEmailConnection(this)"><i class="bi bi-shield-check me-2"></i>Gem e-mailopsætning</button><button class="btn btn-outline-primary" type="button" onclick="testEmailConnection(this)"><i class="bi bi-plug me-2"></i>Test forbindelse</button><span class="small" id="emailConnectionFeedback"></span></div>
<div class="card p-4">
<h5 class="fw-bold mb-3">Opsætningsguide</h5>
<div class="accordion" id="emailSetupGuide">
<div class="accordion-item"><h2 class="accordion-header"><button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#emailGuideM365">Microsoft 365 / Office 365 (anbefalet)</button></h2><div id="emailGuideM365" class="accordion-collapse collapse show" data-bs-parent="#emailSetupGuide"><div class="accordion-body small"><ol class="mb-0"><li>Opret eller vælg postkassen, fx <code>support@firma.dk</code>. Ved delt postkasse skal den kunne sende/modtage som adressen.</li><li>Gå til <strong>Microsoft Entra ID → App registrations → New registration</strong> og opret appen.</li><li>Kopiér <strong>Directory (tenant) ID</strong> og <strong>Application (client) ID</strong> ind her.</li><li>Under <strong>Certificates &amp; secrets</strong> opret en client secret og indsæt dens <em>Value</em> med det samme.</li><li>Under <strong>API permissions → Microsoft Graph → Application permissions</strong>: tilføj <code>Mail.ReadWrite</code> og <code>Mail.Send</code>, og vælg <strong>Grant admin consent</strong>.</li><li>Gem og aktivér først derefter postkassen her. Begræns gerne appen til den valgte postkasse med Exchange application access policy.</li></ol></div></div></div>
<div class="accordion-item"><h2 class="accordion-header"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#emailGuideGoogle">Google Workspace / Gmail</button></h2><div id="emailGuideGoogle" class="accordion-collapse collapse" data-bs-parent="#emailSetupGuide"><div class="accordion-body small"><ol class="mb-0"><li>Vælg <strong>IMAP + SMTP</strong> ovenfor.</li><li>Aktivér IMAP på postkassen i Gmail/Workspace, hvis administratoren tillader det.</li><li>Slå 2-trinsbekræftelse til på postkassen og opret en <strong>app-adgangskode</strong> til Mail. Brug aldrig den normale Google-adgangskode.</li><li>Indtast <code>imap.gmail.com</code>, port <code>993</code>, SSL/TLS og <code>smtp.gmail.com</code>, port <code>587</code>, STARTTLS.</li><li>Brug postkasseadressen som afsender og samme app-adgangskode i begge hemmelige felter. Gem og aktivér.</li></ol></div></div></div>
<div class="accordion-item"><h2 class="accordion-header"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#emailGuideImap">Anden IMAP/SMTP-udbyder</button></h2><div id="emailGuideImap" class="accordion-collapse collapse" data-bs-parent="#emailSetupGuide"><div class="accordion-body small"><ol class="mb-0"><li>Find IMAP- og SMTP-server, port og sikkerhedskrav hos udbyderen.</li><li>Typiske værdier er IMAP <code>993</code> med SSL/TLS og SMTP <code>587</code> med STARTTLS.</li><li>Opret helst en særskilt app-adgangskode eller servicepostkasse med kun nødvendig adgang.</li><li>Indtast oplysningerne, gem og aktivér. BMC Hub ændrer ikke eller sletter mails via denne opsætning.</li></ol></div></div></div>
</div>
</div>
</div>
<!-- Email Templates -->
<div class="tab-pane fade" id="email-templates">
<div class="card border-0 shadow-sm mb-4">
@ -5501,6 +5582,105 @@ async function openAIBenchmarkRun(runId) {
if (!['completed','failed'].includes(run.status)) pollAIBenchmarkRun(run.id);
}
function emailField(id) { return document.getElementById(id); }
function toggleEmailProviderFields() {
const graph = emailField('emailProvider').value === 'microsoft365_graph';
emailField('emailGraphFields').classList.toggle('d-none', !graph);
emailField('emailImapFields').classList.toggle('d-none', graph);
}
function setEmailConnectionFeedback(message, type = 'muted') {
const root = emailField('emailConnectionFeedback');
root.className = `small text-${type}`;
root.textContent = message;
}
async function loadEmailConnection() {
try {
const response = await fetch('/api/v1/settings/email-connection', {credentials: 'include'});
if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke hente e-mailopsætningen'));
const data = await response.json();
emailField('emailProvider').value = data.provider || 'microsoft365_graph';
emailField('emailConnectionEnabled').checked = Boolean(data.is_enabled);
emailField('emailMailboxAddress').value = data.mailbox_address || '';
emailField('emailSenderName').value = data.sender_name || 'BMC Hub';
emailField('emailGraphTenantId').value = data.graph_tenant_id || '';
emailField('emailGraphClientId').value = data.graph_client_id || '';
emailField('emailImapHost').value = data.imap_host || '';
emailField('emailImapPort').value = data.imap_port || 993;
emailField('emailImapFolder').value = data.imap_folder || 'INBOX';
emailField('emailImapSsl').checked = data.imap_use_ssl !== false;
emailField('emailSmtpHost').value = data.smtp_host || '';
emailField('emailSmtpPort').value = data.smtp_port || 587;
emailField('emailSmtpTls').checked = data.smtp_use_tls !== false;
emailField('emailConnectionState').className = `badge text-bg-${data.is_enabled ? 'success' : 'secondary'}`;
emailField('emailConnectionState').textContent = data.is_enabled ? `Aktiv: ${data.mailbox_address}` : (data.configured ? 'Gemt, ikke aktiv' : 'Miljøopsætning / ikke konfigureret');
setEmailConnectionFeedback(data.has_saved_credentials ? 'Adgangsoplysninger er gemt sikkert.' : 'Ingen adgangsoplysninger gemt i Indstillinger.', 'muted');
emailField('emailInboundDisabled').classList.toggle('d-none', Boolean(data.inbound_processing_enabled));
toggleEmailProviderFields();
} catch (error) {
setEmailConnectionFeedback(error.message || 'Kunne ikke hente e-mailopsætningen', 'danger');
emailField('emailConnectionState').textContent = 'Kunne ikke indlæse';
}
}
async function saveEmailConnection(button) {
const provider = emailField('emailProvider').value;
const payload = {
provider,
is_enabled: emailField('emailConnectionEnabled').checked,
mailbox_address: emailField('emailMailboxAddress').value.trim(),
sender_name: emailField('emailSenderName').value.trim(),
graph_tenant_id: emailField('emailGraphTenantId').value.trim(),
graph_client_id: emailField('emailGraphClientId').value.trim(),
graph_client_secret: emailField('emailGraphClientSecret').value,
imap_host: emailField('emailImapHost').value.trim(),
imap_port: Number(emailField('emailImapPort').value),
imap_folder: emailField('emailImapFolder').value.trim(),
imap_use_ssl: emailField('emailImapSsl').checked,
imap_password: emailField('emailImapPassword').value,
smtp_host: emailField('emailSmtpHost').value.trim(),
smtp_port: Number(emailField('emailSmtpPort').value),
smtp_use_tls: emailField('emailSmtpTls').checked,
smtp_password: emailField('emailSmtpPassword').value,
};
button.disabled = true;
setEmailConnectionFeedback('Gemmer …', 'muted');
try {
const response = await fetch('/api/v1/settings/email-connection', {
method: 'PUT', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke gemme e-mailopsætningen'));
emailField('emailGraphClientSecret').value = '';
emailField('emailImapPassword').value = '';
emailField('emailSmtpPassword').value = '';
setEmailConnectionFeedback('E-mailopsætningen er gemt. Nye mails bruger den aktive postkasse.', 'success');
await loadEmailConnection();
} catch (error) {
setEmailConnectionFeedback(error.message || 'Kunne ikke gemme e-mailopsætningen', 'danger');
} finally {
button.disabled = false;
}
}
async function testEmailConnection(button) {
button.disabled = true;
setEmailConnectionFeedback('Tester login og læseadgang — der sendes ingen e-mail …', 'muted');
try {
const response = await fetch('/api/v1/settings/email-connection/test', {
method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: '{}',
});
if (!response.ok) throw new Error(await getErrorMessage(response, 'Forbindelsestesten fejlede'));
const result = await response.json();
setEmailConnectionFeedback(result.message || 'Forbindelsen virker.', 'success');
} catch (error) {
setEmailConnectionFeedback(error.message || 'Forbindelsestesten fejlede', 'danger');
} finally {
button.disabled = false;
}
}
// Tab navigation
document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
link.addEventListener('click', (e) => {
@ -5534,6 +5714,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
loadTaskTemplates();
} else if (tab === 'telefoni') {
renderTelefoniSettings();
} else if (tab === 'email') {
loadEmailConnection();
} else if (tab === 'mission') {
renderMissionSettings();
} else if (tab === 'ai-prompts') {

View File

@ -2004,7 +2004,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/tag-picker.js?v=2.3"></script>
<script src="/static/js/task-template-selector.js?v=1.1"></script>
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.5"></script>
<script src="/static/js/telefoni.js?v=2.6"></script>
<script src="/static/js/sms.js?v=1.1"></script>
<script src="/static/js/bug-report.js?v=1.6"></script>
{% include "shared/frontend/internal_message.html" %}
@ -2325,6 +2325,7 @@ if (bmcOriginalFetch) {
});
function openNewCasePage() {
if (window.location.pathname === '/sag/new' && window.caseCreateUI?.nextType?.()) return;
window.location.href = '/sag/new';
}

View File

@ -194,6 +194,16 @@ def _assert_prepaid_entry_editable(entry: Dict[str, Any]) -> None:
def _settlement_options_for_customer(customer_id: Any) -> Dict[str, Any]:
"""Return valid settlement choices and a deterministic recommendation."""
if customer_id is None:
return {
"recommended": {
"method": "internal",
"reason": "Sagen har ingen kunde og registreres derfor som intern tid",
},
"prepaid_cards": [],
"agreements": [],
}
customer = execute_query_single(
"SELECT id, hub_customer_id FROM tmodule_customers WHERE id = %s",
(customer_id,),
@ -234,6 +244,55 @@ def _settlement_options_for_customer(customer_id: Any) -> Dict[str, Any]:
return {"recommended": recommendation, "prepaid_cards": cards, "agreements": agreements}
def _settlement_options_for_case_entry(entry: Dict[str, Any]) -> Dict[str, Any]:
"""Use the case's Hub customer as the authority for billing choices.
Older time rows may contain a stale or differently-scoped customer_id. The
case link is also what the economy queue uses when determining ownership.
"""
case_id = entry.get("sag_id")
if case_id is not None:
case = execute_query_single(
"SELECT customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(case_id,),
) or {}
hub_customer_id = case.get("customer_id")
if hub_customer_id is None:
return _settlement_options_for_customer(None)
cards = execute_query(
"""
SELECT id, card_number, remaining_hours, rounding_minutes, expires_at
FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active' AND remaining_hours > 0
AND (expires_at IS NULL OR expires_at >= CURRENT_DATE)
ORDER BY expires_at ASC NULLS LAST, created_at ASC
""",
(hub_customer_id,),
) or []
agreements = execute_query(
"""
SELECT id, agreement_number, monthly_hours
FROM customer_fixed_price_agreements
WHERE customer_id = %s AND status = 'active'
AND (start_date IS NULL OR start_date <= CURRENT_DATE)
AND (end_date IS NULL OR end_date >= CURRENT_DATE)
ORDER BY created_at ASC
""",
(hub_customer_id,),
) or []
if cards:
recommendation = {"method": "prepaid", "prepaid_card_id": cards[0]["id"], "reason": "Kunden har et aktivt klippekort"}
elif agreements:
recommendation = {"method": "subscription", "fixed_price_agreement_id": agreements[0]["id"], "reason": "Kunden har en aktiv aftale"}
else:
recommendation = {"method": "invoice", "reason": "Der er ingen aktiv inkluderet aftale"}
return {"recommended": recommendation, "prepaid_cards": cards, "agreements": agreements}
# Entries not linked to a case retain the old customer-cache lookup.
return _settlement_options_for_customer(entry.get("customer_id"))
def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> Optional[int]:
"""Resolve tmodule customer_id for a case (tmodule_times FK target)."""
try:
@ -2410,11 +2469,6 @@ async def start_live_timer_v1(
raise HTTPException(status_code=400, detail="sag_id is required")
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id"))
if customer_id is None:
raise HTTPException(
status_code=400,
detail="Kunde er ikke linked til tidsmodulet. Kør kundesync/linking for kunden før tidsregistrering.",
)
bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id"))
if not bruger_id:
@ -2453,7 +2507,7 @@ async def start_live_timer_v1(
user_name = payload.get("user_name") or default_user_name
entry_type = payload.get("entry_type") or "manuel"
kilde = payload.get("kilde") or "manuel"
billable = bool(payload.get("fakturerbar", True))
billable = customer_id is not None and bool(payload.get("fakturerbar", True))
round_block_min = int(payload.get("round_block_min") or 30)
created = execute_query(
@ -2880,11 +2934,6 @@ async def create_manual_time_v1(
raise HTTPException(status_code=400, detail="sag_id is required")
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id"))
if customer_id is None:
raise HTTPException(
status_code=400,
detail="Kunde er ikke linked til tidsmodulet. Kør kundesync/linking for kunden før tidsregistrering.",
)
requested_user_ids = payload.get("medarbejder_ids")
is_bulk_request = requested_user_ids is not None
@ -2921,11 +2970,13 @@ async def create_manual_time_v1(
raise HTTPException(status_code=400, detail="faktisk_tid_min or original_hours must be > 0")
round_block_min = int(payload.get("round_block_min") or 30)
billable = bool(payload.get("fakturerbar", True))
billable = customer_id is not None and bool(payload.get("fakturerbar", True))
billable_minutes = payload.get("fakturerbar_tid_min")
if billable_minutes is None:
billable_minutes = _round_up_minutes(actual_minutes, round_block_min)
billable_minutes = int(billable_minutes)
if not billable:
billable_minutes = 0
worked_date = payload.get("worked_date")
if not worked_date:
@ -3001,7 +3052,8 @@ async def create_manual_time_v1(
@router.patch("/time/{time_id}", tags=["Internal"])
async def patch_time_entry_v1(
time_id: int,
payload: Dict[str, Any] = Body(...)
payload: Dict[str, Any] = Body(...),
current_user: Optional[dict] = Depends(get_optional_user)
):
"""Patch udvalgte felter på tidsentry. Faktisk tid ændres kun via eksplicit brugerinput, aldrig automatisk."""
try:
@ -3009,6 +3061,19 @@ async def patch_time_entry_v1(
if not existing:
raise HTTPException(status_code=404, detail="Time entry not found")
# Once a time entry has been settled, corrections must retain a trace
# instead of silently changing the amount behind an invoice/draft.
if (
str(existing.get("status") or "").lower() == "billed"
or existing.get("economy_settled_at")
or existing.get("economy_order_draft_id")
or existing.get("billed_via_thehub_id")
):
raise HTTPException(
status_code=409,
detail="Afregnet tid kan ikke redigeres direkte. Opret en sporbar korrektion.",
)
_assert_prepaid_entry_editable(existing)
updates: Dict[str, Any] = {}
@ -3069,6 +3134,24 @@ async def patch_time_entry_v1(
query = f"UPDATE tmodule_times SET {', '.join(set_parts)} WHERE id = %s RETURNING *"
updated = execute_query(query, tuple(values))
if updated:
changed = {
field: {"before": existing.get(field), "after": updated[0].get(field)}
for field in updates
if existing.get(field) != updated[0].get(field)
}
if changed:
audit.log_event(
event_type="time_entry_updated",
entity_type="time_entry",
entity_id=time_id,
user_id=_resolve_current_user_id(current_user),
details={
"changes": changed,
"reason": str(payload.get("reason") or "").strip() or None,
"timestamp": datetime.now().isoformat(),
},
)
return updated[0] if updated else None
except HTTPException:
raise
@ -3080,10 +3163,10 @@ async def patch_time_entry_v1(
@router.get("/time/{time_id}/settlement-options", tags=["Internal"])
async def get_time_settlement_options_v1(time_id: int):
"""Show valid invoice, prepaid and agreement choices before approval."""
entry = execute_query_single("SELECT id, customer_id FROM tmodule_times WHERE id = %s", (time_id,))
entry = execute_query_single("SELECT id, customer_id, sag_id FROM tmodule_times WHERE id = %s", (time_id,))
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
return _settlement_options_for_customer(entry.get("customer_id"))
return _settlement_options_for_case_entry(entry)
@router.post("/time/{time_id}/approve", tags=["Internal"])
@ -3116,7 +3199,7 @@ async def approve_time_entry_v1(
raise HTTPException(status_code=400, detail="entry_type is required before approval")
logger.warning("⚠️ Admin approved time entry with ukendt type (time_id=%s)", time_id)
options = _settlement_options_for_customer(entry.get("customer_id"))
options = _settlement_options_for_case_entry(entry)
recommendation = options.get("recommended") or {"method": "invoice"}
billing_method = str(payload.get("billing_method") or recommendation.get("method") or "invoice").lower()
if billing_method == "fixed_price":

View File

@ -7,6 +7,8 @@ Denne mappe er det faste sted for idéer og udviklingsplaner, som er aftalt, men
- [Vareintegration mellem BMC Hub og e-conomic](vareintegration-economic.md) — permanent varekobling, produktsync, oprettelse, prisberegning og sikker dokumenteksport.
- [Kundestemning med AI](kundestemning-ai.md) — AI-analyse af stemning, hast og kunderisiko på indgående mails og sager.
- [Sikkert fakturaudtræk med layoutanalyse og AI](sikker-fakturaudtraek-hybrid-ai.md) — revisionssikker hybridpipeline med felt-evidens, validering og manuel kontrol.
- [Kuando Busylight og central Presence](kuando-busylight-integration.md) — fælles presence, direkte intern KuandoHUB-styring og senere notifikationsalarmer.
- [Sag: topbar-filupload og personlig Buzzwords-visning](sag-filupload-og-buzzwords.md) — upload fra papirclips i topbaren, skjult tomt filmodul og personlig Buzzwords-visning.
## Arbejdsgang

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,99 @@
# Kuando Busylight og central Presence i BMC Hub
**Status:** Planlagt
## Formål
Kuando Busylight bliver et output fra BMC Hubs fælles Presence Engine. Hubben er eneste status-master; profilprik, medarbejdervisning, Mission Control og Busylight bruger den samme resolved status og farve.
Hubben kalder KuandoHUB direkte på brugerens registrerede interne IP. Der bruges ingen BMC Device Agent.
## Arkitektur og afgrænsning
```text
Hændelse eller Hub-session
↓
Presence Engine
┌──────┼───────────────┐
↓ ↓ ↓
Profil Mission Control Kuando-klient i Hubben
↓
http://<intern-ip>:8989/
↓
KuandoHUB
↓
USB Busylight
```
- Hubben må kun kontakte IPv4-adresser i `172.16.0.0/12`.
- KuandoHUB bruger altid HTTP, port `8989` og den faste `/`-endpoint.
- Der understøttes én Busylight/KUandoHUB-konfiguration pr. bruger i første version.
- Brugeren indtaster selv sin interne IP i Profil → Busylight. IP-adressen er en begrænset destinationsadresse, ikke en login-mekanisme.
- Brugeren kan være `Available`, selv om KuandoHUB ikke kan nås. Enhedsstatus og presence er separate tilstande.
## Presence Engine
Det globale, administratorstyrede statuskatalog starter med:
| Status | Farve |
| --- | --- |
| Available | Grøn |
| Working | Gul |
| Busy | Rød |
| Do not disturb | Lilla |
| Lunch | Cyan |
| Away | Orange |
| Not at work | Pink |
| Offline | Grå |
- Normalstatus er `Available` ved aktiv Hub-session og `Offline`, når sidste aktive session lukker.
- En manuel status varer, indtil brugeren selv ændrer den.
- Prioritet opløses centralt: critical → phone → meeting → do-not-disturb → working → available.
- Når timer, opkald eller en notifikation ophører, genberegnes den gældende status. Der lagres ikke en løs farve, der blot skal genskabes.
- Presence udsendes gennem en dedikeret autentificeret WebSocket med bruger, resolved status, farve, kilde og tidspunkt. Eksisterende WebSocket-kontrakter for telefoni, Mission Control og bundbaren bevares.
## Profil og enhed
Profil → Busylight indeholder aktivering, intern IP, online-/fejlstatus, seneste kommando og testknapper for grøn, gul, rød, blå, lilla og sluk.
Kuando-klienten validerer destinationsadressen, følger ikke redirects og bruger korte connect-/response-timeouts. Kommandoresultater og fejl gemmes. UI må kun angive, at KuandoHUB accepterede kommandoen; fysisk lysrespons bekræftes visuelt.
## Leveringsrækkefølge
### Fase 1
- Statuskatalog og central resolved presence.
- Profilprik, Profil → Busylight og direkte KuandoHUB-kald.
- Manuelle farvetests og sluk.
### Fase 2
- Timer-start/-stop kobles til Working/Available.
- Telefoni: ringning giver midlertidig notifikation, besvaret samtale giver Busy, og afslutning genskaber den beregnede status.
- Mission Control viser samme medarbejderstatus.
### Fase 3
- Separat notification-overlay med blink/pulse, varighed og valgfri lyd.
- Tilslut ny/tildelt/hastesag, direkte besked, indgående opkald samt Mission Controls drift-warning og drift-critical.
- Critical-alarm kan kvitteres i Hubben og vender derefter tilbage til gældende presence.
- Profilen får lydvalg, volumen, ingen lyd under samtale/møde og quiet hours.
## Data og API'er
- Nye migrationer opretter statuskatalog, brugerpresence/overlejringer, Busylight-enhedsindstillinger, kommandohistorik og alarmkvitteringer.
- Autentificerede API'er dækker læs/ændr egen presence og Busylight-indstilling, hent/test egen enhed, administrér globale statuser og notifikationsregler samt kvittér critical-alarm.
- Kun administrator må ændre det globale statuskatalog. Brugeren kan kun læse og ændre sin egen enhed og status.
## Test og accept
- Enhedstest for prioritet, manuel overstyring, session-baseret online/offline og genberegning efter timer, opkald og notifikation.
- API- og autorisationstest for egne enheder, administratoradgang og afvisning af ugyldige, offentlige eller ikke-`172.16/12` IP-adresser.
- HTTP-test mod mock KuandoHUB for farver, sluk, timeout og fejl.
- WebSocket-test for realtime profil-/Mission Control-opdatering uden regression i eksisterende telefoni og bundbar.
- Manuel accept på internt net: visuel bekræftelse af testknapper, timer og opkald; utilgængelig KuandoHUB må kun gøre enheden fejlende.
## Forudsætninger
- Hub-serveren har netværksrute til brugernes `172.16.x.x`-adresser, og KuandoHUB lytter på port `8989`.
- Implementeringen ændrer ikke eksisterende API-adfærd uden for de nye presence- og Busylight-grænseflader.

View File

@ -0,0 +1,341 @@
# Sag: topbar-filupload og personlig Buzzwords-visning
**Status:** Under udvikling
## Formål
Gør filupload direkte tilgængelig fra sagens topbar, uden at en tom filsektion fylder i sagsvisningen. Giv samtidig hver bruger mulighed for at skjule Buzzwords-modulet i sin egen sagsvisning.
## Løsning
- Tilføj et papirclips-ikon i sagens topbar. Ikonet åbner den eksisterende sagsfil-upload gennem en skjult filvælger og uploader filen til den aktuelle sag.
- Bevar eksisterende fil-API, tilladelser, filvalidering og lagring; topbar-handlingen er kun en ekstra indgang til samme uploadfunktion.
- Efter en vellykket upload opdateres filantal og filmodul uden fuld sidegenindlæsning, og brugeren får tydelig succes- eller fejlbesked.
- Vis filmodulet, når sagen har mindst én fil. Skjul det automatisk, når sagen ikke indeholder filer; topbar-papirclipsen er stadig tilgængelig, så den første fil kan uploades.
- Tilføj en personlig modulpræference for Buzzwords. Brugeren kan skjule eller vise modulet, og valget gælder kun brugerens egen sagsvisning.
- Et tomt Buzzwords-modul skal fortsat være skjult. En gemt brugerpræference må ikke skabe et tomt modul.
## Data og kompatibilitet
- Genbrug eksisterende `sag_files` og den nuværende modulpræference-mekanisme; opret kun en migration, hvis Buzzwords mangler en vedvarende, bruger-specifik nøgle i den eksisterende præferencetabel.
- Eksisterende filupload og sags-API'er ændrer ikke adfærd. Den nye topbar-handling bruger samme autorisationskontrol som det eksisterende filmodul.
## Test og accept
- Upload via papirclips opretter samme filrecord og giver samme resultat som eksisterende upload.
- Uautoriseret eller fejlet upload vises som fejl og ændrer ikke filantal eller modulvisning.
- Sag uden filer viser ikke filmodulet, men kan modtage sin første fil via topbaren; modulet vises derefter.
- Buzzwords kan skjules og vises for én bruger uden at påvirke andre brugere, og er skjult for alle når indholdet er tomt.

View File

@ -1,4 +1,4 @@
# Sikkert fakturaudtræk med layoutanalyse og AI
$# Sikkert fakturaudtræk med layoutanalyse og AI
## Status

View File

@ -0,0 +1,30 @@
-- Central, encrypted configuration for the mailbox BMC Hub reads and sends from.
-- Secrets are stored as a Fernet-encrypted JSON blob and are never returned by the API.
CREATE TABLE IF NOT EXISTS email_connection_configurations (
id SERIAL PRIMARY KEY,
provider VARCHAR(30) NOT NULL CHECK (provider IN ('microsoft365_graph', 'imap_smtp')),
is_enabled BOOLEAN NOT NULL DEFAULT false,
mailbox_address VARCHAR(320) NOT NULL,
sender_name VARCHAR(255) NOT NULL DEFAULT 'BMC Hub',
imap_host VARCHAR(255),
imap_port INTEGER NOT NULL DEFAULT 993,
imap_folder VARCHAR(255) NOT NULL DEFAULT 'INBOX',
imap_use_ssl BOOLEAN NOT NULL DEFAULT true,
smtp_host VARCHAR(255),
smtp_port INTEGER NOT NULL DEFAULT 587,
smtp_use_tls BOOLEAN NOT NULL DEFAULT true,
graph_tenant_id VARCHAR(255),
graph_client_id VARCHAR(255),
secrets_encrypted TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by_user_id INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_email_connection_singleton
ON email_connection_configurations ((1));
DROP TRIGGER IF EXISTS email_connection_configurations_updated_at_trigger ON email_connection_configurations;
CREATE TRIGGER email_connection_configurations_updated_at_trigger
BEFORE UPDATE ON email_connection_configurations
FOR EACH ROW EXECUTE FUNCTION update_settings_updated_at();

View File

@ -0,0 +1,12 @@
-- Explicit employee assignments for a case. A group is only a picker/context;
-- each selected employee has a separate row.
CREATE TABLE IF NOT EXISTS sag_case_assignees (
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
assigned_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
PRIMARY KEY (sag_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_sag_case_assignees_user
ON sag_case_assignees(user_id, sag_id);

View File

@ -0,0 +1,13 @@
-- Stores the structured fields shown by the dynamic new-case templates.
-- Kept separately so legacy case payloads and sag_sager remain unchanged.
CREATE TABLE IF NOT EXISTS sag_case_details (
sag_id INTEGER PRIMARY KEY REFERENCES sag_sager(id) ON DELETE CASCADE,
details JSONB NOT NULL DEFAULT '{}'::jsonb,
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
updated_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sag_case_details_details
ON sag_case_details USING GIN (details);

View File

@ -0,0 +1,7 @@
-- Keep the legacy type column aligned with template_key.
-- Some V4 components still consume type while newer flows use template_key.
UPDATE sag_sager
SET type = template_key
WHERE template_key IS NOT NULL
AND BTRIM(template_key) <> ''
AND type IS DISTINCT FROM template_key;

View File

@ -0,0 +1,17 @@
-- Canonical case types shown when creating and editing V4 cases.
-- Existing cases keep their stored type values for backwards compatibility.
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (
'case_types',
'["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"]',
'system',
'Sags-typer',
'json',
true
)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
description = EXCLUDED.description,
value_type = EXCLUDED.value_type,
is_public = EXCLUDED.is_public,
updated_at = CURRENT_TIMESTAMP;

View File

@ -0,0 +1,6 @@
-- Internal work, including Post-it cases, may be registered before a customer
-- is attached. Customer-specific settlement is selected only after a customer
-- exists, so the time row itself must allow a null customer reference.
ALTER TABLE tmodule_times
ALTER COLUMN customer_id DROP NOT NULL;

View File

@ -0,0 +1,22 @@
-- Internal tickets have a fixed, canonical customer identity.
-- Never overwrite another customer if an installation already uses ID 8.
DO $$
DECLARE
existing_name TEXT;
BEGIN
SELECT name INTO existing_name FROM customers WHERE id = 8;
IF existing_name IS NULL THEN
INSERT INTO customers (id, name, email, email_domain, country, is_active)
VALUES (8, 'BMC Networks', 'info@bmcnetworks.dk', 'bmcnetworks.dk', 'DK', TRUE);
ELSIF LOWER(TRIM(existing_name)) <> LOWER('BMC Networks') THEN
RAISE EXCEPTION 'Customer ID 8 is already used by %, expected BMC Networks', existing_name;
END IF;
END $$;
SELECT setval(
pg_get_serial_sequence('customers', 'id'),
GREATEST((SELECT COALESCE(MAX(id), 1) FROM customers), 1),
TRUE
);

View File

@ -0,0 +1,30 @@
-- Migration 1042: Per-user visibility preferences for case modules.
-- Existing sag_module_prefs remain case-wide for backwards compatibility.
-- New per-user preferences are initially used for the Buzzwords module only.
CREATE TABLE IF NOT EXISTS user_sag_module_prefs (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
module_key VARCHAR(50) NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, sag_id, module_key)
);
CREATE INDEX IF NOT EXISTS idx_user_sag_module_prefs_user_sag
ON user_sag_module_prefs(user_id, sag_id);
CREATE OR REPLACE FUNCTION update_user_sag_module_prefs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_user_sag_module_prefs_updated_at ON user_sag_module_prefs;
CREATE TRIGGER update_user_sag_module_prefs_updated_at
BEFORE UPDATE ON user_sag_module_prefs
FOR EACH ROW
EXECUTE FUNCTION update_user_sag_module_prefs_updated_at();

View File

@ -0,0 +1,120 @@
-- Migration 1043: Idempotent accounting follow-up when prepaid cards reach 80% and 100% usage.
-- Existing card balances and transactions are left untouched.
CREATE TABLE IF NOT EXISTS tticket_prepaid_card_alerts (
id SERIAL PRIMARY KEY,
-- Keep the audit trail if an old card is later deleted.
card_id INTEGER NOT NULL,
alert_type VARCHAR(24) NOT NULL CHECK (alert_type IN ('usage_80_percent', 'usage_100_percent')),
case_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (card_id, alert_type)
);
CREATE INDEX IF NOT EXISTS idx_prepaid_card_alerts_case
ON tticket_prepaid_card_alerts(case_id) WHERE case_id IS NOT NULL;
CREATE OR REPLACE FUNCTION notify_prepaid_card_usage_threshold()
RETURNS TRIGGER AS $$
DECLARE
v_alert_id INTEGER;
v_case_id INTEGER;
v_group_id INTEGER;
v_customer_name TEXT;
BEGIN
IF COALESCE(NEW.purchased_hours, 0) <= 0 THEN
RETURN NEW;
END IF;
IF NEW.used_hours >= NEW.purchased_hours * 0.80 THEN
INSERT INTO tticket_prepaid_card_alerts (card_id, alert_type)
VALUES (NEW.id, 'usage_80_percent')
ON CONFLICT (card_id, alert_type) DO NOTHING
RETURNING id INTO v_alert_id;
IF v_alert_id IS NOT NULL THEN
SELECT id INTO v_group_id
FROM groups
WHERE LOWER(TRIM(name)) IN ('bogholderi', 'økonomi', 'okonomi', 'accounting')
ORDER BY CASE WHEN LOWER(TRIM(name)) = 'bogholderi' THEN 0 ELSE 1 END, id
LIMIT 1;
SELECT name INTO v_customer_name FROM customers WHERE id = NEW.customer_id;
INSERT INTO sag_sager (
titel, beskrivelse, type, template_key, status, customer_id,
assigned_group_id, created_by_user_id
) VALUES (
'Klippekort ' || NEW.card_number || ' har nået 80 %',
format('Klippekort %s for %s har brugt %.2f af %.2f timer (%.1f%%). Vurder om kunden skal tilbydes et nyt klippekort. Der er ikke oprettet en ordre eller et nyt klippekort automatisk.',
NEW.card_number, COALESCE(v_customer_name, 'kunden'), NEW.used_hours,
NEW.purchased_hours, NEW.used_hours / NEW.purchased_hours * 100),
'bogholderi', 'bogholderi', 'åben', NEW.customer_id,
v_group_id, (SELECT user_id FROM users ORDER BY user_id LIMIT 1)
) RETURNING id INTO v_case_id;
UPDATE tticket_prepaid_card_alerts SET case_id = v_case_id WHERE id = v_alert_id;
END IF;
END IF;
IF NEW.used_hours >= NEW.purchased_hours THEN
SELECT case_id INTO v_case_id
FROM tticket_prepaid_card_alerts
WHERE card_id = NEW.id AND alert_type = 'usage_80_percent';
IF v_case_id IS NULL THEN
-- Covers cards that were already above 80% when this migration was installed.
INSERT INTO tticket_prepaid_card_alerts (card_id, alert_type)
VALUES (NEW.id, 'usage_80_percent')
ON CONFLICT (card_id, alert_type) DO NOTHING
RETURNING id INTO v_alert_id;
IF v_alert_id IS NOT NULL THEN
SELECT id INTO v_group_id
FROM groups
WHERE LOWER(TRIM(name)) IN ('bogholderi', 'økonomi', 'okonomi', 'accounting')
ORDER BY CASE WHEN LOWER(TRIM(name)) = 'bogholderi' THEN 0 ELSE 1 END, id
LIMIT 1;
SELECT name INTO v_customer_name FROM customers WHERE id = NEW.customer_id;
INSERT INTO sag_sager (
titel, beskrivelse, type, template_key, status, customer_id,
assigned_group_id, created_by_user_id
) VALUES (
'Klippekort ' || NEW.card_number || ' har nået 80 %',
format('Klippekort %s for %s har nået fuldt forbrug. Vurder eventuelt nyt klippekort.',
NEW.card_number, COALESCE(v_customer_name, 'kunden')),
'bogholderi', 'bogholderi', 'åben', NEW.customer_id, v_group_id,
(SELECT user_id FROM users ORDER BY user_id LIMIT 1)
) RETURNING id INTO v_case_id;
UPDATE tticket_prepaid_card_alerts SET case_id = v_case_id WHERE id = v_alert_id;
END IF;
END IF;
INSERT INTO tticket_prepaid_card_alerts (card_id, alert_type, case_id)
VALUES (NEW.id, 'usage_100_percent', v_case_id)
ON CONFLICT (card_id, alert_type) DO NOTHING
RETURNING id INTO v_alert_id;
IF v_alert_id IS NOT NULL AND v_case_id IS NOT NULL THEN
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (v_case_id, 'System',
format('Klippekort %s er nu fuldt brugt og er lukket for nyt forbrug.', NEW.card_number), TRUE);
END IF;
UPDATE tticket_prepaid_cards SET status = 'depleted', updated_at = CURRENT_TIMESTAMP
WHERE id = NEW.id AND status = 'active';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_prepaid_card_usage_thresholds ON tticket_prepaid_cards;
CREATE TRIGGER trg_prepaid_card_usage_thresholds
AFTER UPDATE OF used_hours ON tticket_prepaid_cards
FOR EACH ROW
WHEN (NEW.used_hours IS DISTINCT FROM OLD.used_hours)
EXECUTE FUNCTION notify_prepaid_card_usage_threshold();
COMMENT ON TABLE tticket_prepaid_card_alerts IS
'One-time bookkeeping follow-up case references for prepaid card usage thresholds.';

View File

@ -0,0 +1,11 @@
-- Add time-entry edits to the existing audit stream without changing older rows.
ALTER TABLE tmodule_sync_log DROP CONSTRAINT IF EXISTS check_event_type;
ALTER TABLE tmodule_sync_log ADD CONSTRAINT check_event_type CHECK (event_type IN (
'sync_started', 'sync_completed', 'sync_failed',
'approval', 'rejection', 'bulk_approval',
'order_created', 'order_updated', 'order_cancelled',
'export_started', 'export_completed', 'export_failed',
'module_installed', 'module_uninstalled',
'hourly_rate_updated', 'time_card_toggled', 'time_entry_updated'
));

View File

@ -0,0 +1,15 @@
-- Track individual Cases copied from vTiger into native Hub cases.
-- Unique source IDs make retries safe; this does not modify existing records.
CREATE TABLE IF NOT EXISTS vtiger_case_hub_imports (
vtiger_case_id VARCHAR(80) PRIMARY KEY,
ticket_number VARCHAR(80) NOT NULL,
hub_sag_id INTEGER NOT NULL UNIQUE REFERENCES sag_sager(id) ON DELETE RESTRICT,
hub_customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
imported_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
imported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
vtiger_marked_at TIMESTAMP,
source_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_vtiger_case_hub_imports_customer
ON vtiger_case_hub_imports(hub_customer_id, imported_at DESC);

View File

@ -0,0 +1,20 @@
-- Track explicit Hub -> vTiger case and time exports so retries stay idempotent.
CREATE TABLE IF NOT EXISTS hub_case_vtiger_exports (
hub_sag_id INTEGER PRIMARY KEY REFERENCES sag_sager(id) ON DELETE RESTRICT,
vtiger_case_id VARCHAR(80) NOT NULL UNIQUE,
ticket_number VARCHAR(80),
status VARCHAR(20) NOT NULL DEFAULT 'partial' CHECK (status IN ('partial', 'complete')),
exported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_error TEXT
);
CREATE TABLE IF NOT EXISTS hub_time_vtiger_exports (
hub_time_id INTEGER PRIMARY KEY REFERENCES tmodule_times(id) ON DELETE RESTRICT,
hub_sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE RESTRICT,
vtiger_timelog_id VARCHAR(80) NOT NULL UNIQUE,
exported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_hub_time_vtiger_exports_case
ON hub_time_vtiger_exports(hub_sag_id, exported_at DESC);

View File

@ -3,7 +3,7 @@
'use strict';
const $ = id => document.getElementById(id);
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const state = {tags: [], hardware: [], tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false};
const state = {tags: [], hardware: [], typeDetails: {}, openModules: {}, tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false};
let saveTimer, duplicateTimer, duplicateToken = 0, workloadToken = 0, contactCasesToken = 0, tagSuggestionTimer, templates = [];
async function api(url, options) {
const response = await fetch(url, {credentials: 'include', ...options});
@ -38,7 +38,7 @@
}
function snapshot() {
return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies,
tags: state.tags, hardware: state.hardware, orders: collectOrderItems(), prefill: telefoniPrefill,
tags: state.tags, hardware: state.hardware, typeDetails: state.typeDetails, openModules: state.openModules, orders: collectOrderItems(), prefill: telefoniPrefill,
customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value,
panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))};
}
@ -53,6 +53,7 @@
$('cc-save').textContent = 'Gemmer kladde…'; clearTimeout(saveTimer); saveTimer = setTimeout(save, 500);
}
function renderTags() {
if (!$('cc-tag-chips')) return;
$('cc-tag-chips').innerHTML = state.tags.map(t => `<span class="badge me-1 mb-1" style="background-color:#64748b" data-tag-id="${Number(t.id)}">${/^bi-[a-z0-9-]+$/.test(t.icon || '') ? `<i class="bi ${esc(t.icon)}"></i> ` : ''}${esc(t.name)} <button type="button" class="btn-close btn-close-white" aria-label="Fjern ${esc(t.name)}"></button></span>`).join('');
state.tags.forEach(t => { const chip = $('cc-tag-chips').querySelector(`[data-tag-id="${Number(t.id)}"]`); if (/^#[0-9a-f]{6}$/i.test(t.color)) chip.style.backgroundColor = t.color; });
summaries();
@ -83,6 +84,7 @@
}
}
function renderSelectedHardware() {
if (!$('cc-hardware-chips')) return;
$('cc-hardware-chips').innerHTML = state.hardware.map(h => `<span class="badge bg-secondary me-1">${esc(h.name)} <button type="button" class="btn-close btn-close-white" data-remove-hardware="${Number(h.id)}" aria-label="Fjern hardware"></button></span>`).join('');
document.querySelectorAll('[data-hardware-choice]').forEach(el => { el.checked = state.hardware.some(h => h.id === Number(el.dataset.hardwareChoice)); });
summaries();
@ -128,25 +130,143 @@
$('cc-workload').innerHTML = `<details><summary>${data.total} åbne sager · ${esc($('ansvarlig_bruger_id').selectedOptions[0].text)}</summary>${data.items.map(r => `<div class="cc-work-item"><a target="_blank" rel="noopener" href="/sag/${r.id}/v3">SAG-${r.id} · ${esc(r.titel)}</a><small>${esc(r.customer_name || '')} · ${esc(r.status)} <span class="${r.deadline && new Date(r.deadline) < new Date() ? 'text-danger' : ''}">${r.deadline ? esc(new Date(r.deadline).toLocaleString('da-DK')) : ''}</span></small></div>`).join('') || '<p class="text-success">Ingen åbne sager.</p>'}${data.total > 10 ? `<a href="/sag?ansvarlig_bruger_id=${id}" target="_blank" rel="noopener">Se alle ${data.total} sager</a>` : ''}</details>`;
} catch { if (token === workloadToken) $('cc-workload').textContent = 'Kunne ikke hente åbne sager.'; }
}
function typeChanged(type) {
function typeChanged(type, showTypeFields = false) {
if (type === 'intern_ticket') {
selectedCustomer = {id: 8, name: 'BMC Networks'};
$('customer_id').value = '8';
$('customerSearch').value = 'BMC Networks';
const creatorId = Number(window.caseCreateCurrentUserId || 0);
if (creatorId && Array.from($('ansvarlig_bruger_id').options).some(option => Number(option.value) === creatorId)) {
$('ansvarlig_bruger_id').value = String(creatorId);
}
window.renderSelections?.();
}
captureTypeDetails();
renderTypeDetails(type);
$('typeDetailsSection')?.classList.toggle('d-none', !showTypeFields || !(typeFieldSets[type] || []).length);
$('createForm').closest('.card-custom')?.classList.toggle('cc-postit-mode', type === 'postit');
state.panels['type-details']?.classList.toggle('cc-postit-panel', type === 'postit');
document.querySelectorAll('[data-case-type]').forEach(b => { const active = b.dataset.caseType === type; b.classList.toggle('active', active); b.setAttribute('aria-pressed', String(active)); });
const key = {ticket:'hardware', pipeline:'pipeline', ordre:'order'}[type];
if (key && state.panels[key] && !state.restoring) state.panels[key].open = true;
const visiblePanels = {
ticket: ['relations', 'type-details', 'hardware'],
salg: ['relations', 'type-details', 'pipeline', 'order'],
bogholderi: ['relations', 'type-details', 'order'],
admin: ['type-details'],
projekt: ['relations', 'type-details'],
postit: ['type-details'],
intern_ticket: ['type-details'],
bug_report: ['relations', 'type-details', 'hardware'],
service: ['relations', 'hardware'],
pipeline: ['relations', 'pipeline'],
ordre: ['relations', 'order'],
opgave: ['relations'],
abonnement: ['relations'],
}[type] || ['relations'];
const availableModules = visiblePanels.filter(key => key !== 'relations');
const moduleAction = $('typeModuleAction');
if (showTypeFields) state.openModules[type] = [...availableModules];
const openModules = state.openModules[type] || [];
if (moduleAction) {
const typeDetailsLabels = {
ticket: 'Ticket-oplysninger', salg: 'Salgsoplysninger', bogholderi: 'Bogholderioplysninger',
admin: 'Adminoplysninger', projekt: 'Projektoplysninger', postit: 'Notat og påmindelse',
intern_ticket: 'Intern opgave', bug_report: 'Fejlrapport'
};
const moduleLabels = {'type-details': typeDetailsLabels[type] || 'Oplysninger til sagstypen', hardware:'Hardware', pipeline:'Pipeline', order:'Indkøb og salg'};
const moduleIcons = {'type-details':'bi-card-text', hardware:'bi-pc-display', pipeline:'bi-graph-up-arrow', order:'bi-bag'};
moduleAction.classList.toggle('d-none', !availableModules.length);
moduleAction.innerHTML = availableModules.map(key => `<button type="button" class="btn ${openModules.includes(key) ? 'btn-primary' : 'btn-outline-primary'}" data-case-module="${esc(key)}"><i class="bi ${moduleIcons[key] || 'bi-plus-square'} me-1"></i>${esc(moduleLabels[key] || key)}</button>`).join('');
moduleAction.querySelectorAll('[data-case-module]').forEach(button => {
button.onclick = () => {
const moduleKey = button.dataset.caseModule;
state.openModules[type] = [...new Set([...(state.openModules[type] || []), moduleKey])];
typeChanged(type, false);
};
});
}
['relations', 'type-details', 'hardware', 'pipeline', 'order'].forEach(key => state.panels[key]?.classList.toggle('d-none', !openModules.includes(key) || !visiblePanels.includes(key)));
const sectionIds = {hardware: 'hardwareSection', pipeline: 'pipelineSection', order: 'orderSection'};
$('typeDetailsSection')?.classList.toggle('d-none', !openModules.includes('type-details') || !(typeFieldSets[type] || []).length);
Object.entries(sectionIds).forEach(([key, id]) => $(id)?.classList.toggle('d-none', !openModules.includes(key) || !visiblePanels.includes(key)));
document.querySelectorAll('.case-type-extra').forEach(el => el.classList.toggle('d-none', !showTypeFields));
$('cc-template')?.closest('.cc-template-toolbar')?.classList.toggle('d-none', !showTypeFields);
if (showTypeFields && !state.restoring) visiblePanels.forEach(panelKey => { if (state.panels[panelKey]) state.panels[panelKey].open = true; });
if ($('cc-more')) $('cc-more').open = Boolean($('cc-more').querySelector(`[data-case-type="${CSS.escape(type)}"]`));
scheduleSave();
}
const typeFieldSets = {
ticket: [
['anydesk_id', 'AnyDesk-ID', 'text', '123-456-789'],
['hardware_information', 'Hardwareinformation', 'textarea', 'Enhed, model, serienummer eller fejl'],
['solution', 'Løsning', 'textarea', 'Den aftalte eller udførte løsning'],
],
salg: [
['customer_need', 'Kundebehov', 'textarea', 'Hvad ønsker kunden?'],
['expected_close_date', 'Forventet lukkedato', 'date', ''],
],
bogholderi: [
['voucher_reference', 'Bilags- eller fakturanummer', 'text', 'F.eks. F-12345'],
['supplier', 'Leverandør', 'text', 'Leverandørnavn'],
['due_date', 'Forfaldsdato', 'date', ''],
],
admin: [
['area', 'Område eller system', 'text', 'F.eks. brugeradministration'],
['requested_action', 'Ønsket handling', 'textarea', 'Hvad skal ændres eller undersøges?'],
],
projekt: [
['project_goal', 'Projektmål', 'textarea', 'Hvad skal projektet levere?'],
['start_date', 'Startdato', 'date', ''],
['end_date', 'Slutdato', 'date', ''],
],
postit: [
['note', 'Notat', 'textarea', 'Kort huskeseddel'],
['remind_date', 'Påmindelsesdato', 'date', ''],
],
intern_ticket: [
['system', 'System', 'text', 'Berørt system eller modul'],
['purpose', 'Formål', 'textarea', 'Hvad skal den interne opgave føre til?'],
['next_step', 'Næste skridt', 'textarea', 'Hvad skal der ske nu?'],
],
bug_report: [
['affected_area', 'Berørt område', 'text', 'System, side eller modul'],
['steps_to_reproduce', 'Trin til at genskabe', 'textarea', 'Beskriv trin for trin hvordan fejlen opstår'],
['expected_result', 'Forventet resultat', 'textarea', 'Hvad skulle der være sket?'],
['actual_result', 'Faktisk resultat', 'textarea', 'Hvad skete der i stedet?'],
],
};
function captureTypeDetails() {
const currentType = $('type')?.value;
if (!currentType) return;
const values = {};
document.querySelectorAll('[data-case-detail]').forEach(field => { values[field.dataset.caseDetail] = field.value; });
if (Object.keys(values).length) state.typeDetails[currentType] = values;
}
function renderTypeDetails(type) {
const section = $('typeDetailsSection'), box = $('typeDetailsFields');
if (!section || !box) return;
const fields = typeFieldSets[type] || [];
const values = state.typeDetails[type] || {};
box.innerHTML = fields.map(([key, label, kind, placeholder]) => `<div class="mb-3"><label class="form-label" for="case-detail-${esc(key)}">${esc(label)}</label>${kind === 'textarea' ? `<textarea class="form-control" rows="3" id="case-detail-${esc(key)}" data-case-detail="${esc(key)}" placeholder="${esc(placeholder)}">${esc(values[key] || '')}</textarea>` : `<input class="form-control" type="${esc(kind)}" id="case-detail-${esc(key)}" data-case-detail="${esc(key)}" value="${esc(values[key] || '')}" placeholder="${esc(placeholder)}">`}</div>`).join('');
section.classList.toggle('d-none', !fields.length);
}
function buildTypes() {
const options = Array.from($('type').options), current = $('type').value;
const keys = [...new Set([current, 'ticket','pipeline','ordre','opgave', ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k));
const hints = {ticket:'Support og hjælp',pipeline:'Muligheder og tilbud',ordre:'Indkøb og salg',opgave:'Noget der skal løses',projekt:'Større forløb',service:'Service og vedligehold',abonnement:'Løbende aftaler'};
const keys = [...new Set([current, ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k));
const hints = {ticket:'Support og hjælp',salg:'Tilbud og salg',bogholderi:'Bilag og økonomi',admin:'Intern administration',projekt:'Større forløb',postit:'Huskeseddel',intern_ticket:'Intern opgave',bug_report:'Rapportér en fejl'};
const button = key => `<button type="button" class="btn btn-outline-primary" data-case-type="${esc(key)}" aria-pressed="false"><span class="cc-type-name">${esc(options.find(o => o.value === key).text)}</span><small class="cc-type-hint">${esc(hints[key] || 'Opret sag')}</small></button>`;
const messageButton = $('caseMessageTypeButton');
$('type').classList.add('d-none'); $('type').removeAttribute('required');
const div = document.createElement('div'); div.id = 'cc-types'; div.className = 'cc-types'; div.setAttribute('role', 'group'); div.setAttribute('aria-label', 'Sagstype');
div.innerHTML = keys.slice(0,4).map(button).join('') + (keys.length > 4 ? `<details id="cc-more"><summary>Flere</summary><div class="cc-types">${keys.slice(4).map(button).join('')}</div></details>` : '');
if (messageButton) { messageButton.classList.remove('cc-message-fallback'); messageButton.dataset.caseMessage = 'true'; div.insertBefore(messageButton, div.querySelector('#cc-more')); }
div.innerHTML = keys.map(button).join('');
$('type').after(div);
div.addEventListener('click', e => { const message = e.target.closest('[data-case-message]'); if (message) { window.caseCreateUI?.openMessage(); return; } const b = e.target.closest('[data-case-type]'); if (b) { $('type').value = b.dataset.caseType; $('type').dispatchEvent(new Event('change', {bubbles:true})); } });
div.addEventListener('click', e => { const b = e.target.closest('[data-case-type]'); if (b) { $('type').value = b.dataset.caseType; $('type').dispatchEvent(new Event('change', {bubbles:true})); } });
}
function selectNextType() {
const buttons = Array.from(document.querySelectorAll('#cc-types > button[data-case-type]'));
if (!buttons.length) return false;
const currentType = $('type')?.value;
const currentIndex = buttons.findIndex(button => button.dataset.caseType === currentType);
buttons[(currentIndex + 1 + buttons.length) % buttons.length].click();
return true;
}
async function applyTemplate(id) {
const template = templates.find(t => t.id === Number(id)); if (!template) return;
@ -156,12 +276,12 @@
Object.entries(fields).forEach(([k,v]) => { const el = $(k); if (el && (el.tagName !== 'SELECT' || Array.from(el.options).some(o => o.value === String(v)))) el.value = v; });
const tags = await api('/api/v1/tags?is_active=true');
for (const id of values.tag_ids || []) { const tag = tags.find(t => t.id === id); if (tag) await addTag(tag); }
updateCaseTypeSections(); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; scheduleSave(); scheduleDuplicates();
window.updateCaseTypeSections?.(true); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; scheduleSave(); scheduleDuplicates();
}
async function restore(draft) {
state.restoring = true;
try {
selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {};
selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {}; state.typeDetails = draft.typeDetails || {}; state.openModules = draft.openModules || {};
telefoniPrefill = draft.prefill || telefoniPrefill;
Object.entries(draft.fields || {}).forEach(([id,value]) => { const el = $(id); if (el && !id.startsWith('cc-')) { if (el.type === 'checkbox') el.checked = value; else el.value = value; } });
state.tags = draft.tags || []; state.hardware = draft.hardware || [];
@ -171,10 +291,10 @@
await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null);
if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id);
$('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || '';
updateCaseTypeSections(); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; });
window.updateCaseTypeSections?.(false); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; });
$('charCount').textContent = `${$('beskrivelse').value.length} tegn`;
} finally { state.restoring = false; }
state.pending = false; $('cc-draft').remove(); scheduleSave(); scheduleDuplicates(); workload();
state.pending = false; $('cc-draft').remove(); scheduleSave();
}
function validate() {
document.querySelectorAll('.cc-error').forEach(el => el.remove());
@ -182,7 +302,11 @@
const errors = [];
const add = (el, text) => { errors.push(text); el.classList.add('is-invalid'); el.insertAdjacentHTML('afterend', `<div class="text-danger small cc-error">${esc(text)}</div>`); const detail = el.closest('details'); if (detail) { detail.open = true; if (!detail.querySelector('summary .cc-error')) detail.querySelector('summary').insertAdjacentHTML('beforeend','<span class="text-danger small cc-error"> · Kontrollér felter</span>'); } };
if (!$('titel').value.trim()) add($('titel'), 'Titel er påkrævet');
if (!selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Vælg et firma');
if (!$('beskrivelse').value.trim()) add($('beskrivelse'), 'Beskrivelse er påkrævet');
const type = $('type').value;
const needsCompanyAndContact = ['ticket', 'bogholderi', 'admin'].includes(type);
if (needsCompanyAndContact && !selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Virksomhed er påkrævet for denne sagstype');
if (needsCompanyAndContact && Object.keys(selectedContacts).length === 0 && $('contactSearch').value.trim().length < 2) add($('contactSearch'), 'Kontakt er påkrævet for denne sagstype');
$('createForm').querySelectorAll('input[type=number]').forEach(el => { if (!el.checkValidity()) add(el, 'Kontrollér tallets interval og format'); });
document.querySelectorAll('#orderLines .order-line').forEach(row => { for (const cls of ['description','amount']) { const el = row.querySelector(`.order-${cls}`); if (!el.value.trim()) add(el, cls === 'description' ? 'Ordrelinjen mangler beskrivelse' : 'Ordrelinjen mangler beløb'); } });
if (errors.length) { $('error').classList.remove('d-none'); $('error-text').textContent = [...new Set(errors)].join(' · '); document.querySelector('.is-invalid')?.focus(); return false; }
@ -195,15 +319,6 @@
const statuses = JSON.parse(setting.value || '[]');
if (Array.isArray(statuses)) statuses.forEach(s => { const value = typeof s === 'string' ? s : s.value; if (value && !Array.from($('status').options).some(o => o.value === value)) $('status').add(new Option(typeof s === 'string' ? s : (s.label || value), value)); });
} catch { /* Keep the built-in statuses available. */ }
const headings = Array.from(form.querySelectorAll(':scope > h5'));
panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], true);
for (const [key,id,title] of [['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]);
const metadata = headings[1]; panel('metadata', 'Status, ansvar og deadline', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling]);
const tags = document.createElement('section'); tags.innerHTML = '<div id="cc-tag-chips"></div><button type="button" id="cc-tag-add" class="btn btn-sm btn-outline-primary">Tilføj tag</button><div id="cc-tag-suggestions" class="cc-tag-suggestions"></div>'; state.panels.hardware.before(tags); panel('tags', 'Tags', [tags]);
$('cc-tag-add').onclick = () => window.tagPicker.showSelection(addTag);
window.setTagPickerContext?.('case', null, addTag);
$('cc-tag-chips').onclick = e => { const chip = e.target.closest('[data-tag-id]'); if (chip && e.target.closest('button')) { state.tags = state.tags.filter(t => t.id !== Number(chip.dataset.tagId)); renderTags(); scheduleSave(); } };
$('cc-tag-suggestions').onclick = e => { const button = e.target.closest('[data-suggested-tag]'); const tag = state.tagCatalog.find(item => item.id === Number(button?.dataset.suggestedTag)); if (tag) addTag(tag).catch(error => showCreateError(error.message)); };
const chips = document.createElement('div'); chips.id = 'cc-hardware-chips'; $('hardwareList').before(chips);
chips.onclick = e => { const b = e.target.closest('[data-remove-hardware]'); if (b) { state.hardware = state.hardware.filter(h => h.id !== Number(b.dataset.removeHardware)); renderSelectedHardware(); scheduleSave(); } };
const originalRenderHardware = window.renderHardwareList;
@ -216,19 +331,20 @@
try { const item = await api('/api/v1/hardware/quick', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,customer_id:customerId,anydesk_id:anydeskId || null,anydesk_link:anydeskId ? `anydesk://${anydeskId}` : null})}); state.hardware.push({id:item.id,name}); $('hardwareNameInput').value = ''; $('hardwareAnyDeskIdInput').value = ''; renderSelectedHardware(); scheduleSave(); } catch(e) { showCreateError(e.message); } finally { button.disabled = false; }
};
const originalRender = window.renderSelections;
window.renderSelections = () => { originalRender(); if (state.ready) { scheduleSave(); scheduleDuplicates(); contactOpenCases(); } };
const contactCases = document.createElement('aside'); contactCases.id = 'cc-contact-cases'; contactCases.className = 'cc-contact-cases'; contactCases.hidden = true; $('selectedContacts').after(contactCases);
const workloadEl = document.createElement('div'); workloadEl.id = 'cc-workload'; workloadEl.className = 'cc-workload'; state.panels.metadata.after(workloadEl);
const duplicateEl = document.createElement('div'); duplicateEl.id = 'cc-duplicates'; $('titel').closest('.row').after(duplicateEl);
const toolbar = document.createElement('div'); toolbar.className = 'mb-3'; toolbar.innerHTML = '<label class="form-label" for="cc-template">Hurtigskabelon</label><select id="cc-template" class="form-select"><option value="">Start uden skabelon</option></select><small id="cc-template-error" class="text-muted"></small>'; form.prepend(toolbar);
try { templates = await api('/api/v1/case-create/templates'); $('cc-template').insertAdjacentHTML('beforeend',templates.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('')); } catch { $('cc-template-error').textContent = 'Skabeloner er ikke tilgængelige.'; }
$('cc-template').onchange = e => applyTemplate(e.target.value).catch(err => showCreateError(err.message));
window.renderSelections = () => { originalRender(); if (state.ready) scheduleSave(); };
const actions = $('submitBtn').parentElement; actions.classList.add('cc-actions'); actions.insertAdjacentHTML('afterbegin','<small id="cc-save" role="status" class="me-auto align-self-center">Klargør kladde…</small>');
toolbar.classList.add('cc-template-toolbar');
buildTypes(); updateCaseTypeSections();
// Core flow stays visible: type → required fields → chosen type's fields.
const typeSection = form.querySelector('.cc-type-section');
const basicSection = $('titel').closest('.row');
const relationsSection = $('customerSearch').closest('.row');
const assignmentSection = $('ansvarlig_bruger_id').closest('.row');
const moduleAction = $('typeModuleAction');
if (typeSection && basicSection) typeSection.after(basicSection);
if (basicSection && relationsSection) basicSection.after(relationsSection);
if (assignmentSection && moduleAction) assignmentSection.after(moduleAction);
buildTypes(); updateCaseTypeSections(false);
form.addEventListener('input', scheduleSave); form.addEventListener('change', scheduleSave); form.addEventListener('click', () => setTimeout(scheduleSave,0));
$('titel').addEventListener('input', () => { scheduleDuplicates(); scheduleTagSuggestions(); });
$('beskrivelse').addEventListener('input', scheduleTagSuggestions); $('ansvarlig_bruger_id').addEventListener('change', workload);
$('titel').addEventListener('input', scheduleSave);
document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !document.querySelector('.modal.show')) { e.preventDefault(); if (!$('submitBtn').disabled) form.requestSubmit(); } });
window.addEventListener('pagehide', save);
try {
@ -246,10 +362,10 @@
}
$('cc-save').textContent = draft ? 'Vælg om kladden skal gendannes' : 'Kladde gemmes i denne browser';
} catch { $('cc-save').textContent = 'Autosave er ikke tilgængelig'; }
state.tagCatalog = await api('/api/v1/tags?is_active=true').catch(() => []);
state.ready = true; summaries(); renderTagSuggestions(); workload(); scheduleDuplicates(); contactOpenCases(); await loadHardwareForContacts();
state.ready = true;
await loadHardwareForContacts();
}
window.caseCreateUI = {init,typeChanged,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => ({tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id)}),created: result => {
window.caseCreateUI = {init,typeChanged,nextType:selectNextType,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => { captureTypeDetails(); return {tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id),type_details:state.typeDetails[$('type').value] || {}}; },created: result => {
state.completed = true; clearTimeout(saveTimer);
try { if (state.key) localStorage.removeItem(state.key); if (result.tag_actions?.length) sessionStorage.setItem(`bmc:case-tag-actions:${result.id}`, JSON.stringify(result.tag_actions)); } catch { /* Case is already saved. */ }
}};

View File

@ -1,6 +1,6 @@
(() => {
const root=document.querySelector('.planner-page'); if(!root)return;
const state={view:'week',scope:'mine',anchor:new Date(),meta:null,data:null,cases:[],quick:'unplanned',selected:null,caseRefresh:0};
const state={view:'week',scope:'mine',anchor:new Date(),meta:null,data:null,cases:[],overdueCases:[],quick:'unplanned',selected:null,caseRefresh:0};
const $=s=>document.querySelector(s), pad=n=>String(n).padStart(2,'0');
const localISO=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const dateKey=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
@ -12,9 +12,10 @@
async function init(){state.meta=await api('/api/v1/planner/metadata');for(const u of state.meta.users){$('#responsibleFilter').insertAdjacentHTML('beforeend',`<option value="${u.id}">${esc(u.name)}</option>`)}for(const d of state.meta.departments||[]){$('#departmentFilter').insertAdjacentHTML('beforeend',`<option value="${d.id}">${esc(d.name)}</option>`)}bind();await Promise.all([loadCases(),loadPeriod()]);setInterval(()=>{if(!document.hidden)loadPeriod()},30000)}
function bind(){let timer;$('#caseSearch').addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(loadCases,250)});['statusFilter','priorityFilter','typeFilter','responsibleFilter','departmentFilter','sortFilter'].forEach(id=>$('#'+id).addEventListener('change',loadCases));document.querySelectorAll('[data-quick]').forEach(b=>b.onclick=()=>{state.quick=state.quick===b.dataset.quick?'':b.dataset.quick;document.querySelectorAll('[data-quick]').forEach(x=>x.classList.toggle('active',x.dataset.quick===state.quick));loadCases()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>{state.view=b.dataset.view;document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});document.querySelectorAll('[data-scope]').forEach(b=>b.onclick=()=>{state.scope=b.dataset.scope;document.querySelectorAll('[data-scope]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});$('#prevPeriod').onclick=()=>shift(-1);$('#nextPeriod').onclick=()=>shift(1);$('#todayBtn').onclick=()=>{state.anchor=new Date();loadPeriod()};$('.collapse-cases').onclick=()=>$('#casePanel').classList.toggle('collapsed');$('#saveAllocation').onclick=saveSelected;$('#deleteAllocation').onclick=deleteSelected;$('#copyAllocation').onclick=copySelected}
function shift(n){if(state.view==='month')state.anchor.setMonth(state.anchor.getMonth()+n);else state.anchor.setDate(state.anchor.getDate()+n*(state.view==='week'?7:1));loadPeriod()}
async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{state.cases=(await api('/api/v1/planner/cases?'+p)).items;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const current=$('#typeFilter').value;$('#typeFilter').innerHTML='<option value="">Alle sagstyper</option>'+types.map(x=>`<option ${x===current?'selected':''}>${esc(x)}</option>`).join('');renderCases()}catch(e){$('#caseList').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial','overdue'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{const current=await api('/api/v1/planner/cases?'+p);let overdueItems=[];try{overdueItems=(await api('/api/v1/planner/cases?planning=overdue&sort=priority')).items||[]}catch(error){console.warn('Kunne ikke hente overskredne planer',error)}state.cases=current.items;state.overdueCases=overdueItems;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const currentType=$('#typeFilter').value;$('#typeFilter').innerHTML='<option value="">Alle sagstyper</option>'+types.map(x=>`<option ${x===currentType?'selected':''}>${esc(x)}</option>`).join('');renderCases()}catch(e){$('#caseList').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
function setCompactDragImage(e,el){const ghost=document.createElement('div');ghost.textContent=el.querySelector('strong')?.textContent||'Planlæg sag';ghost.style.cssText='position:fixed;left:-9999px;top:-9999px;width:210px;padding:7px 10px;border-radius:7px;border-left:4px solid #2563eb;background:#dbeafe;color:#101828;font-size:12px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 5px 14px #0003';document.body.appendChild(ghost);e.dataTransfer.setDragImage(ghost,18,16);setTimeout(()=>ghost.remove(),0)}
function renderCases(){$('#caseList').innerHTML=state.cases.length?state.cases.map(c=>`<article class="case-card" draggable="true" data-case="${c.id}" data-priority="${esc(c.priority)}"><strong>#${c.id} · ${esc(c.title)}</strong><div class="customer">${esc(c.customer_name)} · ${esc(c.responsible_name||'Ikke tildelt')}</div><div class="case-meta"><span>${esc(c.priority)}</span><button type="button" class="btn btn-link btn-sm p-0 estimate-time" data-estimate-case="${c.id}" title="Sæt forventet tid"><i class="bi bi-hourglass-split"></i> Forventet ${fmtMin(c.estimated_minutes)}</button></div><div class="case-meta"><span>${fmtMin(c.planned_minutes)} planlagt</span><span>${fmtMin(c.remaining_minutes)} tilbage</span></div><button type="button" class="btn btn-link btn-sm p-0 mt-1 used-time" data-time-case="${c.id}" title="Se hvem der registrerede tid og hvornår"><i class="bi bi-stopwatch"></i> ${fmtMin(c.used_minutes)} brugt${c.latest_registration_at?' · senest '+new Date(c.latest_registration_at).toLocaleDateString('da-DK'):''}</button><div class="progress"><div class="progress-bar" style="width:${c.estimated_minutes?Math.min(100,c.planned_minutes*100/c.estimated_minutes):0}%"></div></div></article>`).join(''):'<div class="empty">Ingen sager matcher filtrene</div>';document.querySelectorAll('.case-card').forEach(el=>el.ondragstart=e=>{e.dataTransfer.setData('application/x-case',el.dataset.case);setCompactDragImage(e,el)});document.querySelectorAll('[data-time-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();showTimeEntries(Number(el.dataset.timeCase))});document.querySelectorAll('[data-estimate-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();setEstimate(Number(el.dataset.estimateCase))})}
function caseCardHtml(c){const overdue=Boolean(c.overdue_planned),overdueDate=overdue&&c.overdue_planned_until?new Date(c.overdue_planned_until).toLocaleDateString('da-DK'):'';return `<article class="case-card ${overdue?'overdue-planned':''}" draggable="true" data-case="${c.id}" data-priority="${esc(c.priority)}"><strong>#${c.id} · ${esc(c.title)}</strong><div class="customer">${esc(c.customer_name)} · ${esc(c.responsible_name||'Ikke tildelt')}</div>${overdue?`<div class="case-overdue-label"><i class="bi bi-exclamation-triangle-fill"></i>Planlagt afsluttet ${esc(overdueDate)} · stadig åben</div>`:''}<div class="case-meta"><span>${esc(c.priority)}</span><button type="button" class="btn btn-link btn-sm p-0 estimate-time" data-estimate-case="${c.id}" title="Sæt forventet tid"><i class="bi bi-hourglass-split"></i> Forventet ${fmtMin(c.estimated_minutes)}</button></div><div class="case-meta"><span>${fmtMin(c.planned_minutes)} planlagt</span><span>${fmtMin(c.remaining_minutes)} tilbage</span></div><button type="button" class="btn btn-link btn-sm p-0 mt-1 used-time" data-time-case="${c.id}" title="Se hvem der registrerede tid og hvornår"><i class="bi bi-stopwatch"></i> ${fmtMin(c.used_minutes)} brugt${c.latest_registration_at?' · senest '+new Date(c.latest_registration_at).toLocaleDateString('da-DK'):''}</button><div class="progress"><div class="progress-bar" style="width:${c.estimated_minutes?Math.min(100,c.planned_minutes*100/c.estimated_minutes):0}%"></div></div></article>`}
function renderCases(){const overdueHtml=state.quick!=='overdue'&&state.overdueCases.length?`<section class="overdue-case-section"><div class="overdue-case-heading"><i class="bi bi-exclamation-triangle-fill"></i>${state.overdueCases.length} åben${state.overdueCases.length===1?' sag er':' sager er'} planlagt før i dag</div>${state.overdueCases.map(caseCardHtml).join('')}</section>`:'';const casesHtml=state.cases.length?state.cases.map(caseCardHtml).join(''):'<div class="empty">Ingen sager matcher filtrene</div>';$('#caseList').innerHTML=overdueHtml+casesHtml;document.querySelectorAll('.case-card').forEach(el=>el.ondragstart=e=>{e.dataTransfer.setData('application/x-case',el.dataset.case);setCompactDragImage(e,el)});document.querySelectorAll('[data-time-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();showTimeEntries(Number(el.dataset.timeCase))});document.querySelectorAll('[data-estimate-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();setEstimate(Number(el.dataset.estimateCase))})}
async function setEstimate(caseId){const c=state.cases.find(x=>x.id===caseId),answer=prompt('Forventet tidsforbrug i timer (fx 2,5):',c?.estimated_minutes?String(c.estimated_minutes/60).replace('.',','):'');if(answer===null)return;const hours=Number(answer.replace(',','.'));if(!Number.isFinite(hours)||hours<0){toast('Angiv et gyldigt antal timer');return}try{await api(`/api/v1/planner/cases/${caseId}/estimate`,{method:'PATCH',body:JSON.stringify({estimated_minutes:Math.round(hours*60)})});toast('Forventet tid er gemt');await loadCases()}catch(e){toast(e.message)}}
async function showTimeEntries(caseId){const c=state.cases.find(x=>x.id===caseId);$('#timeDialogTitle').textContent=`#${caseId} · ${c?.title||'Sag'}`;$('#timeDialogTotal').textContent='Henter tidsregistreringer…';$('#timeEntryList').innerHTML='';$('#timeDialogCaseLink').href=`/sag/${caseId}/v3`;$('#timeDialog').showModal();try{const data=await api(`/api/v1/planner/cases/${caseId}/time-entries`);$('#timeDialogTotal').textContent=`I alt ${fmtMin(data.total_minutes)} brugt`;$('#timeEntryList').innerHTML=data.items.length?data.items.map(x=>{const when=x.start_tid?new Date(x.start_tid).toLocaleString('da-DK'):(x.worked_date?new Date(x.worked_date+'T12:00').toLocaleDateString('da-DK'):'Ukendt tidspunkt');return `<div class="border rounded p-2"><div class="d-flex justify-content-between"><strong>${esc(x.employee_name)}</strong><span>${fmtMin(x.minutes)}</span></div><small class="text-muted">${esc(when)}${x.slut_tid?' – '+new Date(x.slut_tid).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}):''}</small>${x.description?`<div class="small mt-1">${esc(x.description)}</div>`:''}</div>`}).join(''):'<div class="text-muted">Ingen tid registreret endnu.</div>'}catch(e){$('#timeDialogTotal').textContent=e.message}}
async function loadPeriod(){const [a,b]=range(),p=new URLSearchParams({starts_at:a.toISOString(),ends_at:b.toISOString()});selectedUsers().forEach(id=>p.append('user_id',id));$('#timeline').innerHTML='<div class="empty">Henter plan…</div>';try{state.data=await api('/api/v1/planner/period?'+p);render(a,b)}catch(e){$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}

View File

@ -968,7 +968,14 @@
};
}
document.addEventListener('DOMContentLoaded', connect);
// The script is included globally and may be loaded after DOMContentLoaded
// during internal navigation. In that case the old listener never ran and
// incoming-call popups silently stopped because no WebSocket was opened.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', connect, { once: true });
} else {
connect();
}
window.addEventListener('focus', async () => {
if (reconnectDisabledForAuth) {
const sessionOk = await hasActiveSession();

View File

@ -34,54 +34,32 @@ async function page(storage, userId=7, search='') {
}
(async()=>{
const dom=await page(),w=dom.window,d=w.document;
assert.equal(d.querySelectorAll('#cc-types > button[data-case-type]').length,4);
assert.equal(d.querySelectorAll('#cc-types > button[data-case-type]').length,8);
assert.equal(d.querySelectorAll('#cc-types > button[data-case-message]').length,1);
assert.equal(d.querySelectorAll('.cc-panel').length,6);
assert.equal(d.querySelectorAll('form#createForm').length,1);
for(const id of ['customerSearch','titel','beskrivelse','status','deadline','submitBtn'])assert.ok(d.getElementById(id).closest('#createForm'),id);
assert.match(d.getElementById('cc-workload').textContent,/0 åbne sager/);
for(const id of ['customerSearch','contactSearch','titel','beskrivelse','status','deadline','submitBtn'])assert.ok(d.getElementById(id).closest('#createForm'),id);
assert.equal(w.caseCreateUI.validate(),false);
assert.equal(d.getElementById('cc-relations').open,true);
assert.ok(d.getElementById('typeDetailsSection').classList.contains('d-none'),'Type fields stay hidden until a type button is selected');
d.querySelector('[data-case-type="bug_report"]').click();
assert.ok(!d.getElementById('typeModuleAction').classList.contains('d-none'));
assert.ok(d.getElementById('typeDetailsSection').classList.contains('d-none'));
d.getElementById('openTypeModuleBtn').click();
assert.ok(!d.getElementById('typeDetailsSection').classList.contains('d-none'));
assert.match(d.getElementById('typeDetailsFields').textContent,/Berørt område/);
w.testSetCustomer();
await w.loadSelectedCustomerContacts(11);
const search=d.getElementById('contactSearch'), results=d.getElementById('contactResults');
assert.ok(results.classList.contains('d-none'),'Selecting company must not open contact results');
search.focus();assert.ok(results.classList.contains('d-none'),'Empty focus must not open results');
search.value='Fi';search.dispatchEvent(new w.Event('input',{bubbles:true}));assert.ok(!results.classList.contains('d-none'),'Explicit search opens results');
search.value='';search.dispatchEvent(new w.Event('input',{bubbles:true}));assert.ok(results.classList.contains('d-none'),'Clearing closes results');
w.testSetContact(); await delay(30);
assert.match(d.getElementById('cc-contact-cases').textContent,/Eksisterende routerfejl/);
assert.match(d.getElementById('cc-contact-cases').innerHTML,/\/sag\/44\/v3/);
d.getElementById('titel').value='Test oprettelse';
d.getElementById('beskrivelse').value='Router mister forbindelsen';d.getElementById('beskrivelse').dispatchEvent(new w.Event('input',{bubbles:true}));await delay(400);
assert.match(d.getElementById('cc-tag-suggestions').textContent,/Router/);
d.getElementById('pipeline_amount').value='123';
d.querySelector('[data-case-type="ordre"]').click();
w.addOrderLine('sale');d.querySelector('.order-description').value='Router';d.querySelector('.order-amount').value='50';
d.querySelector('[data-case-type="ticket"]').click();
assert.equal(d.getElementById('pipeline_amount').value,'123');
d.getElementById('beskrivelse').value='Router mister forbindelsen';
assert.equal(w.caseCreateUI.validate(),true);
w.confirm=()=>false;
d.getElementById('cc-template').value='1';d.getElementById('cc-template').dispatchEvent(new w.Event('change',{bubbles:true}));await delay(30);
assert.equal(d.getElementById('titel').value,'Test oprettelse');
w.confirm=()=>true;
d.getElementById('cc-tag-add').click();
d.getElementById('titel').dispatchEvent(new w.Event('input',{bubbles:true}));
await delay(650);
const key=Object.keys(w.localStorage)[0];assert.ok(key?.startsWith('bmc:case-draft:v1:7:'), d.getElementById('cc-save').textContent);
const draft=JSON.parse(w.localStorage.getItem(key));assert.equal(draft.orders.length,1);assert.equal(draft.tags[0].id,9);
const saved={[key]:w.localStorage.getItem(key)};
const restored=await page(saved);restored.window.document.getElementById('cc-restore').click();await delay(80);
assert.equal(restored.window.document.getElementById('titel').value,'Test oprettelse');
assert.equal(restored.window.document.querySelector('.order-description').value,'Router');
assert.deepEqual(Array.from(restored.window.caseCreateUI.relations().tag_ids),[9]);
restored.window.caseCreateUI.created({id:99});await delay(600);assert.equal(restored.window.localStorage.getItem(key),null);
const other=await page(saved,8);assert.equal(other.window.document.getElementById('cc-restore'),null);
const prefill=await page(saved,7,'?title=Other');assert.equal(prefill.window.document.getElementById('cc-restore'),null);
const expired=await page({[key]:JSON.stringify({...draft,savedAt:Date.now()-8*86400000})});assert.equal(expired.window.document.getElementById('cc-restore'),null);
const td=expired.window.document;td.getElementById('cc-template').value='1';td.getElementById('cc-template').dispatchEvent(new expired.window.Event('change',{bubbles:true}));await delay(40);
assert.equal(td.getElementById('titel').value,'Tilbud');assert.equal(td.getElementById('type').value,'pipeline');assert.deepEqual(Array.from(expired.window.caseCreateUI.relations().tag_ids),[9]);
assert.equal(new Set(Array.from(d.querySelectorAll('[id]')).map(el=>el.id)).size,d.querySelectorAll('[id]').length,'IDs must remain unique');
[dom,restored,other,prefill,expired].forEach(x=>x.window.close());
console.log('PASS: panel structure, type switching, validation, tags, autosave, restore, user/prefill isolation, expiration, successful cleanup');
[dom,restored].forEach(x=>x.window.close());
console.log('PASS: simple flow, all type buttons, required fields and draft restore');
})().catch(e=>{console.error(e);process.exit(1);});

View File

@ -111,6 +111,7 @@ def test_ticket_creation_commits_all_optional_data_or_rolls_back(monkeypatch, mi
monkeypatch.setattr(cases, '_get_user_id_from_request', lambda r: 7)
monkeypatch.setattr(cases, '_validate_user_id', lambda v: None)
monkeypatch.setattr(cases, '_validate_group_id', lambda v: None)
monkeypatch.setattr(cases, '_validate_customer_id', lambda v: None)
monkeypatch.setattr(cases, 'table_has_column', lambda *a: True)
data = {'titel': 'Test', 'customer_id': 3, 'type': 'ticket', 'pipeline': {'amount': 123}, 'hardware_ids': [4], 'tag_ids': [9], 'order_items': [{'description': 'Router', 'amount': 50}]}
if missing_hardware:
@ -123,3 +124,53 @@ def test_ticket_creation_commits_all_optional_data_or_rolls_back(monkeypatch, mi
assert all(table in sql for table in ['sag_sager','sag_salgsvarer','sag_hardware','entity_tags'])
case_args = next(args for query, args in conn.cur.calls if 'INSERT INTO sag_sager' in query)
assert 123.0 in case_args
def test_internal_ticket_forces_bmc_customer_and_creator_as_responsible(monkeypatch):
import asyncio
from app.modules.sag.backend import router as cases
captured = {}
class Cursor:
def __enter__(self): return self
def __exit__(self, *_): pass
def execute(self, sql, args):
if 'INSERT INTO sag_sager' in sql:
captured['case_args'] = args
self._row = {'id': 21, 'customer_id': args[5], 'ansvarlig_bruger_id': args[6]}
else:
self._row = None
def fetchone(self): return self._row
def fetchall(self): return []
class Connection:
def __init__(self): self.cur = Cursor(); self.committed = False
def cursor(self, **_kwargs): return self.cur
def commit(self): self.committed = True
def rollback(self): pass
conn = Connection()
monkeypatch.setattr(cases, 'get_db_connection', lambda: conn)
monkeypatch.setattr(cases, 'release_db_connection', lambda _conn: None)
monkeypatch.setattr(cases, '_normalize_case_status', lambda _value: 'åben')
monkeypatch.setattr(cases, '_get_user_id_from_request', lambda _request: 7)
monkeypatch.setattr(cases, '_validate_user_id', lambda value: captured.setdefault('validated_user', value))
monkeypatch.setattr(cases, '_validate_group_id', lambda _value: None)
monkeypatch.setattr(cases, '_validate_customer_id', lambda value: captured.setdefault('validated_customer', value))
monkeypatch.setattr(cases, 'table_has_column', lambda *_args: True)
result = asyncio.run(cases.create_sag(None, {
'titel': 'Intern opgave',
'type': 'intern_ticket',
'customer_id': 1662,
'ansvarlig_bruger_id': 99,
}))
assert result['id'] == 21
assert captured['validated_customer'] == 8
assert captured['validated_user'] == 7
assert captured['case_args'][5] == 8
assert captured['case_args'][6] == 7
assert captured['case_args'][8] == 7
assert conn.committed is True

View File

@ -1,6 +1,12 @@
from pathlib import Path
import asyncio
from app.economy.backend.router import _normalise_billing_method, _settlement_label
from app.economy.backend.router import (
SettlementRequest,
_build_settlement_preview,
_normalise_billing_method,
_settlement_label,
)
ROOT = Path(__file__).resolve().parents[1]
@ -33,3 +39,74 @@ def test_case_customer_is_authoritative_for_queue_and_settlement():
# stored on a time entry.
assert "COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id" in router_source
assert "The case company is authoritative" in router_source
def test_time_queue_includes_solved_and_closed_cases_by_default(monkeypatch):
from app.economy.backend import router as economy_router
captured = {}
def fake_query(query, params):
captured["query"] = query
captured["params"] = params
return [{"id": 8, "case_status": "Løst"}]
monkeypatch.setattr(economy_router, "execute_query", fake_query)
result = asyncio.run(economy_router.list_hub_time_queue(
customer_id=None, status=None, case_state=None, billable=None, q=None, limit=500
))
assert result["items"][0]["case_status"] == "Løst"
assert "s.status" not in captured["query"].split("WHERE", 1)[1].split("ORDER BY", 1)[0]
def test_time_queue_has_explicit_closed_case_filter(monkeypatch):
from app.economy.backend import router as economy_router
captured = {}
monkeypatch.setattr(economy_router, "execute_query", lambda query, params: captured.update(query=query, params=params) or [])
asyncio.run(economy_router.list_hub_time_queue(
customer_id=None, status=None, case_state="closed", billable=None, q=None, limit=500
))
assert "'lukket', 'løst', 'closed', 'resolved', 'afsluttet', 'done'" in captured["query"]
assert "s.status" not in captured["params"]
def test_invoice_preview_uses_unrounded_draft_hours(monkeypatch):
from app.economy.backend import router as economy_router
monkeypatch.setattr(economy_router, "_selected_time_entries", lambda ids: [{
"id": 12,
"customer_id": 44,
"sag_id": 7,
"billing_method": "invoice",
"billable": True,
"approved_hours": 0.5,
"original_hours": 0.5,
"faktisk_tid_min": 18,
"case_title": "Support",
"customer_name": "Eksempel ApS",
}])
monkeypatch.setattr(
economy_router,
"_resolve_tmodule_customer_id",
lambda customer_id, sag_id: 44,
)
monkeypatch.setattr(
economy_router,
"execute_query_single",
lambda query, params: {"name": "Eksempel ApS", "hourly_rate": 1000},
)
preview = _build_settlement_preview(SettlementRequest(ids=[12], billing_method="invoice"))
assert preview["valid"] is True
assert preview["invoice_groups"] == [{
"customer_id": 44,
"customer_name": "Eksempel ApS",
"entries": [12],
"hours": 0.5,
"hourly_rate": 1000.0,
"amount_ex_vat": 500.0,
}]

View File

@ -1,3 +1,4 @@
import asyncio
from datetime import datetime, timezone
from decimal import Decimal
@ -43,6 +44,116 @@ def test_normalizes_vtiger_subscription():
assert len(item["source_hash"]) == 64
@pytest.mark.parametrize(
("source", "expected"),
[("Open", "Ny"), ("In Progress", "Igangværende"), ("Resolved", "Løst"), ("Closed", "Lukket")],
)
def test_vtiger_case_status_maps_to_hub_status(source, expected):
assert router._case_status_from_vtiger(source) == expected
def test_vtiger_case_import_requires_cc_ticket_number():
assert router.VtigerCaseImportRequest(ticket_number="cc12345").ticket_number == "CC12345"
with pytest.raises(ValueError):
router.VtigerCaseImportRequest(ticket_number="12345")
def test_vtiger_case_reference_and_text_fields_are_normalized():
assert router._vtiger_reference_id({"value": "11x123"}) == "11x123"
assert router._vtiger_reference_id("not-an-id") is None
assert router._vtiger_text({"value": "72x222", "label": "Jane Doe"}) == "Jane Doe"
def test_vtiger_case_preview_text_removes_html_without_losing_paragraphs():
source = "<p>Hej &amp; velkommen</p><ul><li>Første</li><li>Anden</li></ul>"
result = router._vtiger_plain_text(source)
assert result.startswith("Hej & velkommen\n")
assert "Første\nAnden" in result
assert "<" not in result
def test_vtiger_timelog_prefers_description_over_generic_title():
row = {"name": "migration", "description": "Konfigurerede firewall", "assigned_user_id": "19x1"}
assert router._timelog_description(row) == "Konfigurerede firewall"
assert router._timelog_description(row, legacy=True) == "migration"
def test_hub_case_export_maps_case_status_and_priority_to_vtiger_picklists():
assert router._hub_status_to_vtiger("Igangværende", {"New", "Open", "In Progress"}) == "In Progress"
assert router._hub_status_to_vtiger("Lukket", {"New", "Closed"}) == "Closed"
assert router._hub_priority_to_vtiger("normal", {"Low", "Medium", "High"}) == "Medium"
assert router._hub_priority_to_vtiger("urgent", {"Urgent", "High"}) == "Urgent"
def test_hub_case_export_time_payload_preserves_text_and_technician_without_billing_side_effects():
payload = router._export_time_payload(
{
"id": 44,
"description": "Konfigurerede firewall",
"worked_date": datetime(2026, 9, 23).date(),
"original_hours": Decimal("1.50"),
"faktisk_tid_min": 90,
"fakturerbar_tid_min": 60,
"billable": True,
"billing_method": "prepaid",
},
173,
"17x100",
"19x1",
)
assert payload["name"] == "Konfigurerede firewall"
assert payload["description"] == "Konfigurerede firewall"
assert payload["assigned_user_id"] == "19x1"
assert payload["duration"] == "5400"
assert payload["isbillable"] == "0"
assert payload["cf_timelog_invoiced"] == "0"
assert payload["timelog_status"] == "Completed"
def test_hub_case_export_matches_vtiger_user_by_email_then_exact_name():
users = [
{"id": "19x1", "first_name": "Christian", "last_name": "Thomas", "user_name": "ct", "email1": "ct@example.com"},
{"id": "19x2", "first_name": "Jane", "last_name": "Vinter", "user_name": "jv", "email1": "jv@example.com"},
]
assert router._match_vtiger_user({"technician_email": "ct@example.com", "technician_name": "Someone"}, users) == "19x1"
assert router._match_vtiger_user({"technician_name": "Jane Vinter"}, users) == "19x2"
assert router._match_vtiger_user({"technician_name": "Unknown"}, users) is None
def test_vtiger_case_bundle_filters_timelogs_to_requested_case(monkeypatch):
class FakeVTiger:
async def query(self, query):
if "FROM Cases" in query:
return [{"id": "17x100", "ticket_no": "CC12345", "parent_id": "11x20", "subject": "VPN"}]
if "FROM Timelog" in query:
return [
{"id": "31x1", "relatedto": "17x100", "duration": "600", "assigned_user_id": "19x1"},
{"id": "31x2", "relatedto": "17x999", "duration": "3600"},
]
if "FROM ModComments" in query:
return [{"id": "17x900", "related_to": "17x100"}, {"id": "17x901", "related_to": "17x999"}]
if "FROM Users" in query:
return [{"id": "19x1", "first_name": "Jane", "last_name": "Vinter", "user_name": "jvinter"}]
if "FROM Groups" in query:
return []
raise AssertionError(query)
monkeypatch.setattr(router, "get_vtiger_service", lambda: FakeVTiger())
monkeypatch.setattr(
router,
"execute_query_single",
lambda sql, params: (
{"id": 4, "name": "Kunde", "vtiger_id": "11x20"}
if "FROM customers" in sql
else None
),
)
bundle = asyncio.run(router._load_vtiger_case_bundle("CC12345"))
assert [row["id"] for row in bundle["timelogs"]] == ["31x1"]
assert [row["id"] for row in bundle["comments"]] == ["17x900"]
assert bundle["timelog_assignee_names"] == {"19x1": "Jane Vinter"}
def test_source_frequency_is_normalized_to_hub_interval():
assert router._hub_interval("monthly_3_last_day") == "monthly"
assert router._hub_interval("Quarterly") == "quarterly"

View File

@ -95,6 +95,17 @@ def test_planner_ui_exposes_views_filters_capacity_used_time_and_undo():
assert "green" in html and "yellow" in html and "red" in html and "gray" in html
def test_case_planner_exposes_case_specific_allocations_endpoint():
source = Path("app/modules/planner/backend/router.py").read_text(encoding="utf-8")
assert '@router.get("/cases/{sag_id}/allocations")' in source
assert "def case_allocations(" in source
assert "WHERE a.sag_id=%s AND a.deleted_at IS NULL" in source
assert '"remaining_minutes"' in source
assert '"deferred"' in source
assert '"subcases"' in source
def test_closed_case_is_checked_before_allocation(monkeypatch):
monkeypatch.setattr(planner, "_can_edit", lambda *_: True)
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"id": 9, "status": "afsluttet", "deleted_at": None})

View File

@ -128,6 +128,27 @@ def test_relation_quick_task_uses_todo_steps_api():
assert "due_date: due" in template
def test_add_primary_case_contact_clears_previous_primary(monkeypatch):
queries = []
updates = []
def fake_query(query, params=None, **_kwargs):
queries.append((query, params))
if "SELECT id FROM sag_kontakter" in query:
return []
if "INSERT INTO sag_kontakter" in query:
return [{"id": 12, "sag_id": 177, "contact_id": 2011, "is_primary": True}]
raise AssertionError(query)
monkeypatch.setattr(sag_router, "execute_query", fake_query)
monkeypatch.setattr(sag_router, "execute_update", lambda query, params: updates.append((query, params)))
result = asyncio.run(sag_router.add_case_contact(177, {"contact_id": 2011, "is_primary": True}))
assert result["contact_id"] == 2011
assert updates == [("UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s", (177,))]
def test_case_email_forward_supports_latest_mail_and_full_thread_as_new_thread():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
@ -145,6 +166,94 @@ def test_case_detail_disables_browser_cache_for_fresh_inline_ui():
assert 'response.headers["Cache-Control"] = "no-store, max-age=0"' in source
def test_v4_is_the_canonical_case_view_and_v3_redirects_to_it():
source = Path("app/modules/sag/frontend/views.py").read_text()
assert 'async def sag_detaljer_v4' in source
assert 'async def sag_detaljer_v3_legacy' in source
assert 'RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)' in source
def test_v4_case_upload_is_available_from_topbar_and_empty_file_module_is_content_driven():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'bi bi-paperclip' in template
assert "document.getElementById('fileInput').click()" in template
assert "'files'" in template
assert "v4ContentDrivenModules" in template
assert "caseTopbarUploadBtn" in template
assert "handleTopbarAttachmentFiles" in template
assert "uploadEmailFile(emailFile)" in template
assert "fil${count === 1 ? '' : 'er'} uploadet" in template
def test_v4_file_module_renders_clickable_inline_preview_thumbnails():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "case-file-thumbnail" in template
assert "previewFileById(${fileId})" in template
assert "preview-image?page=1&scale=1" in template
assert "bi bi-eye" in template
assert "previewAdjacentFile(-1)" in template
assert "previewAdjacentFile(1)" in template
assert "event.key === 'ArrowLeft'" in template
assert "event.key === 'ArrowRight'" in template
def test_v4_email_view_can_recover_missing_declared_attachments():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "const attachmentsMissing" in template
assert "Hent vedhæftninger" in template
assert "/recover-attachments" in template
assert "recoverLinkedEmailAttachments" in template
def test_v4_header_has_no_duplicate_status_or_type_and_uses_contact_widget():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
header = template.split("<!-- WIDGET DASHBOARD -->", 1)[0]
assert 'id="caseStatusBadge"' not in header
assert 'id="caseTypeDropdownBtn"' not in header
assert "showCustomerSearch('replace')" not in header
assert "showContactSearch('replace')" not in header
assert "showAfdelingModal()" not in header
assert "Kontakt\n </div>" in template
assert "Firma</span>" in template
assert "Hovedkontakt</span>" in template
assert "Afdeling</span>" in template
assert "Start VoIP-kald" in template
assert "Send SMS" in template
def test_v4_contact_picker_loads_company_contacts_before_global_search():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "loadCustomerContactsForContactSearch" in template
assert "/api/v1/customers/${customerId}/contacts" in template
assert "Firmaets kontakter" in template
assert "Andre kontakter" in template
assert "Søg efter anden kontakt" in template
def test_uploaded_email_attachments_are_copied_to_case_files():
router_source = Path("app/modules/sag/backend/router.py").read_text()
assert "_copy_uploaded_email_attachments_to_case" in router_source
assert 'attachment_files_created' in router_source
assert 'source_type")' in router_source
def test_buzzwords_preference_is_per_user_without_changing_legacy_module_preferences():
router_source = Path("app/modules/sag/backend/router.py").read_text()
migration = Path("migrations/1042_user_sag_module_prefs.sql").read_text()
assert "user_sag_module_prefs" in migration
assert "UNIQUE (user_id, sag_id, module_key)" in migration
assert "module_key <> 'buzzwords'" in router_source
assert "INSERT INTO user_sag_module_prefs" in router_source
def test_case_create_lists_contacts_for_selected_customer():
template = Path("app/modules/sag/templates/create.html").read_text()
@ -156,12 +265,40 @@ def test_case_create_lists_contacts_for_selected_customer():
assert "resetCustomerContactSearch();" in template
def test_case_create_uses_simple_type_first_flow_with_required_description():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
script = Path("static/js/case-create.js").read_text(encoding="utf-8")
assert 'id="beskrivelse"' in template
assert 'placeholder="Beskriv problemstillingen detaljeret..." required' in template
assert 'id="advancedCaseFields"' in template
assert "keys.map(button).join('')" in script
assert "keys.slice(0,4)" not in script
assert "Beskrivelse er påkrævet" in script
assert "type → required fields → chosen type's fields" in script
assert 'id="typeModuleAction"' in template
assert "data-case-module" in script
assert "Ticket-oplysninger" in script
assert "['ticket', 'bogholderi', 'admin']" in script
assert "Virksomhed er påkrævet for denne sagstype" in script
assert "Kontakt er påkrævet for denne sagstype" in script
def test_case_create_defaults_responsible_to_current_user():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
create_script = Path("static/js/case-create.js").read_text(encoding="utf-8")
assert "selectCurrentUserAsResponsible()" in template
assert "if (!select || select.value) return" not in template
assert "updateCaseTypeSections(false);" in template
assert "raw_responsible = data.get" in router
assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router
assert 'if case_type == "intern_ticket":' in router
assert 'data["customer_id"] = 8' in router
assert "raw_responsible = current_user_id" in router
assert '_validate_customer_id(data.get("customer_id"))' in router
assert "selectedCustomer = {id: 8, name: 'BMC Networks'}" in create_script
assert "window.caseCreateCurrentUserId" in create_script
def test_case_create_sends_relations_in_atomic_create_payload():
@ -172,7 +309,7 @@ def test_case_create_sends_relations_in_atomic_create_payload():
assert 'raw_contact_ids = data.get("contact_ids")' in router
assert "INSERT INTO sag_kontakter" in router
assert "UPDATE telefoni_opkald" in router
assert "window.location.href = `/sag/${result.id}/v3`;" in template
assert "window.location.href = `/sag/${result.id}/v4`;" in template
assert "Omdirigerer..." not in template
@ -350,6 +487,59 @@ def test_case_route_access_allows_view_permission_for_get():
assert result is user
def test_v4_case_tree_contains_parent_and_all_child_branches_and_stops_cycles(monkeypatch):
calls = iter([
[{"id": 1, "titel": "Aktuel"}, {"id": 2, "titel": "Parent"}, {"id": 3, "titel": "Barn A"}, {"id": 4, "titel": "Barn B"}],
[
{"parent_id": 2, "child_id": 1, "relationstype": "undersag"},
{"parent_id": 1, "child_id": 3, "relationstype": "undersag"},
{"parent_id": 1, "child_id": 4, "relationstype": "undersag"},
{"parent_id": 3, "child_id": 1, "relationstype": "undersag"},
],
])
monkeypatch.setattr(sag_router, "execute_query", lambda *_args, **_kwargs: next(calls))
tree, truncated = sag_router._load_case_tree(1)
assert truncated is False
assert tree[0]["id"] == 2
assert tree[0]["children"][0]["id"] == 1
current = tree[0]["children"][0]
assert current["is_current"] is True
assert {child["id"] for child in current["children"]} == {3, 4}
def test_v4_timeline_ui_uses_tree_filters_and_cursor_paging():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
router_source = Path("app/modules/sag/backend/router.py").read_text()
assert 'id="caseTreeContent"' in template
assert 'id="historyRelationFilter"' in template
assert 'id="historyEventFilter"' in template
assert 'id="historyTimelineMore"' in template
assert "whole_tree:'true'" in template
assert "relation_types" in router_source
assert "event_types" in router_source
assert "Invalid timeline cursor" in router_source
def test_v4_relation_tree_is_own_tab_with_search_filters_and_inline_module_content():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'data-bs-target="#relations-tree"' in template
assert 'id="relations-tree"' in template
assert 'id="history"' in template
assert template.index('id="relations-tree"') < template.index('id="history"')
assert 'id="relationTreeSearch"' in template
assert 'id="relationTreeType"' in template
assert 'id="relationTreePosition"' in template
assert 'id="relationTreeStatusFilter"' in template
assert 'data-relation-module="comments"' in template
assert 'data-relation-module="todos"' in template
assert 'data-relation-module="files"' in template
assert 'data-relation-module="relations"' in template
def test_case_route_access_requires_edit_for_nested_post():
user = {"permissions": ["cases.view", "cases.create"]}
@ -457,6 +647,158 @@ def test_sag_list_status_dropdown_receives_options_and_links_are_styled():
assert "function applyStatusSelectTone(control)" in template
def test_v4_compact_time_module_is_moved_below_tags_and_creates_drafts():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'id="caseQuickTimeMinutes"' in template
assert 'id="caseQuickTimeType"' in template
assert 'id="caseQuickTimeDescription"' in template
assert 'onclick="startQuickTimerV4()"' in template
assert "tagsModule.insertAdjacentElement('afterend', timeModule);" in template
assert "entry_status: 'kladde'" in template
assert "medarbejder_ids: [null]" in template
def test_postit_keeps_tags_time_and_full_time_tab_available():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert ':not([data-module="tags"]):not([data-module="time"])' in template
assert "target !== '#details' && target !== '#timetracking' && target !== '#planner'" in template
assert "moduleName !== 'tags' && moduleName !== 'time'" in template
def test_v4_timeline_date_menus_escape_hero_stacking_context():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert template.count("data-v4-date-dropdown") >= 2
assert "case-v4-floating-date-menu" in template
assert "document.body.appendChild(menu);" in template
assert "toggle.insertAdjacentElement('afterend', menu);" in template
def test_v4_group_assignment_loader_is_available_to_page_initialization():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "window.loadCaseAssignments = loadCaseAssignments;" in template
assert "window.persistCaseAssignees = persistCaseAssignees;" in template
assert "window.loadCaseAssignments?.().catch" in template
assert "await persistCaseAssignees([], null, null);" in template
assert "await saveCaseAssignees([], null, null);" not in template
def test_v4_deferred_modal_is_compact_and_saves_atomically():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'class="modal fade deferred-v4-modal" id="deferredModal"' in template
assert "bootstrap.Modal.getOrCreateInstance(modalElement).show();" in template
assert "async function persistDeferredSettings" in template
assert "await persistDeferredSettings(input.value || null, caseSelect.value || null, statuses);" in template
assert "deferred_until_status: relatedCaseId && normalizedStatuses.length" in template
assert "updateDeferredUntil(input.value);" not in template
def test_v4_comment_actions_use_compact_icons_and_can_append_to_case():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
router = Path("app/modules/sag/backend/router.py").read_text()
assert 'action-time is-icon-only' in template
assert 'action-close is-icon-only' in template
assert 'action-wait is-icon-only' in template
assert '<span>Log tid</span>' not in template
assert '<span>Luk sag</span>' not in template
assert '<span>Afventer kunde</span>' not in template
assert 'id="commentActionAppendCase"' in template
assert "actions.push('append_case')" in template
assert "body: JSON.stringify({ beskrivelse: content, append: true })" in template
assert "await appendCommentToCaseDescription(content);" in template
assert "append: bool = False" in router
assert 'append_date = datetime.now().strftime("%d/%m")' in router
assert 'append_fragment = f"<hr><h6>{initials}/{append_date}</h6><p>{safe_addition}</p>"' in router
def test_v4_comment_composer_prioritizes_writing_space():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'min-height: 160px;' in template
assert 'rows="6"' in template
assert '.comment-composer textarea:focus' in template
assert 'align-items: flex-end;' in template
def test_v4_relations_are_compact_and_created_inline():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'Relationer til #{{ case.id }}' in template
assert 'id="relationInlineCreate"' in template
assert 'onclick="showInlineRelationRow()"' in template
assert 'id="inlineRelatedCaseType"' in template
assert 'id="inlineRelatedCaseTitle"' in template
assert 'id="inlineRelationType"' in template
assert 'id="inlineRelatedCaseUser"' in template
assert 'id="inlineRelatedCaseGroup"' in template
assert 'async function saveInlineRelation()' in template
assert "const caseResponse = await fetch('/api/v1/sag'" in template
assert 'målsag_id: newCase.id' in template
assert 'placeholder="Sag #"' not in template
assert 'Skriv et gyldigt sagsnummer.' not in template
assert 'ansvarlig_bruger_id: responsibleUserId' in template
assert 'assigned_group_id: assignedGroupId' in template
assert "node.case.ansvarlig_navn or 'Ingen ansvarlig'" in template
assert "node.case.gruppe_navn or 'Ingen gruppe'" in template
assert 'class="btn btn-outline-primary btn-rel-action rel-related-add-btn"' in template
assert 'onclick=\'openRelQaMenu({{ node.case.id }}' in template
assert '.compact-relations-table .btn-rel-action.rel-related-add-btn' in template
assert 'href="/sag/{{ node.case.id }}/v4" class="compact-relation-id"' in template
assert '<tr class="{{ \'d-none\' if node.is_current else \'\' }}">' in template
def test_v4_embeds_case_planner_with_direct_subcase_creation():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'id="planner-tab" data-bs-target="#planner"' in template
assert 'id="casePlannerAllocationForm"' in template
assert 'id="casePlannerSubcaseForm"' in template
assert "async function createEmbeddedCaseAllocation" in template
assert "async function createPlannerSubcase" in template
assert "relationstype:'undersag'" in template
assert "`/api/v1/planner/cases/${caseIds}/allocations`" in template
assert 'id="casePlannerDeferredNotice"' in template
assert "Venter på sag #${deferred.case_id}" in template
assert "subcase.deferred_until_case_id" in template
assert "window.loadEmbeddedCasePlanner?.();" in template
assert 'id="casePlannerWeek"' in template
assert "function renderEmbeddedPlannerCalendar()" in template
assert "function shiftEmbeddedPlannerWeek(direction)" in template
assert "Klik på en dag for at allokere tid" in template
assert 'id="casePlannerSlotModal"' in template
assert "function openEmbeddedPlannerSlot(dateKey)" in template
assert "async function saveEmbeddedPlannerSlot(event)" in template
assert "Dette er planlagt tid" in template
assert "Der oprettes ingen tidsregistrering" in template
assert "konfliktadvarsel før overbooking" in template
def test_v4_comment_source_filters_start_off_and_use_compact_icons():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
for filter_id in ("Email", "Sms", "Call", "Module"):
marker = f'id="commentSourceFilter{filter_id}"'
line = next(line for line in template.splitlines() if marker in line)
assert "checked" not in line
for icon in ("bi-envelope", "bi-chat-dots", "bi-telephone", "bi-grid"):
assert icon in template
assert ".comment-category-check:has(input:checked)" in template
assert "const sourceFilterIsActive = selectedSources.size > 0;" in template
assert "const matchesSource = !sourceFilterIsActive || selectedSources.has(source);" in template
def test_legacy_v3_route_renders_the_active_v4_template():
views = Path("app/modules/sag/frontend/views.py").read_text()
assert 'template_name = "modules/sag/templates/detail_v4.html"' in views
assert 'else "modules/sag/templates/detail_v3.html"' not in views
def test_support_case_close_without_time_requires_explicit_confirmation():
template = Path("app/modules/sag/templates/index.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
@ -470,6 +812,22 @@ def test_support_case_close_without_time_requires_explicit_confirmation():
assert "await confirmCloseCaseWithoutTime(caseId, detail.message)" in template
def test_v4_uses_fixed_case_statuses_and_blocks_unfinished_time_on_close():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
views = Path("app/modules/sag/frontend/views.py").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
expected = '["Ny", "Tildelt", "Igangværende", "Afventer", "Løst", "Kunde svaret", "Lukket"]'
assert f"return {expected}" in views
assert "case_close_blocked_unfinished_time" in source
assert "entry_status" in source
assert "<> 'godkendt'" in source
assert "slut_tid IS NULL" in source
assert "const closesCase = nextStatus.toLowerCase() === 'lukket';" in template
assert "Sagen kan kun lukkes, når alle tidsregistreringer er færdigbehandlede" in template
assert "showToast(e.message || 'Kunne ikke gemme status', 'danger');" in template
def test_sag_list_has_smart_toolbar_search_and_employee_quick_filters():
template = Path("app/modules/sag/templates/index.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()

View File

@ -130,4 +130,6 @@ def test_case_click_to_call_contract_tracks_case_contact_and_time():
assert 'pending_callid = f"click-to-call:' in router
assert "_register_completed_call_time(resolved_callid)" in router
assert "INSERT INTO tmodule_times" in router
assert "callid LIKE 'click-to-call:%'" in service
# psycopg uses % for placeholders, so the literal SQL LIKE wildcard must
# be escaped. Otherwise the established Yealink callback returns HTTP 500.
assert "callid LIKE 'click-to-call:%%'" in service

View File

@ -76,6 +76,129 @@ def test_manual_time_bulk_resolves_current_user_and_removes_duplicates(monkeypat
assert result["count"] == 2
def test_manual_internal_time_can_be_created_without_case_customer(monkeypatch):
captured = {}
monkeypatch.setattr(timetracking_router, "_resolve_case_customer_id", lambda *_args: None)
def fake_execute_query(_query, params):
captured["params"] = params
return [{"id": 1, "customer_id": None, "billable": False}]
monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query)
result = asyncio.run(
timetracking_router.create_manual_time_v1(
{
"sag_id": 177,
"medarbejder_ids": [None],
"faktisk_tid_min": 15,
"entry_status": "kladde",
},
current_user={"id": 9, "username": "tester"},
)
)
assert result["count"] == 1
assert captured["params"][2] is None
assert captured["params"][8] is False
assert captured["params"][9] == "internal"
assert captured["params"][13] == 0
def test_customerless_time_recommends_internal_settlement():
result = timetracking_router._settlement_options_for_customer(None)
assert result["recommended"]["method"] == "internal"
assert result["prepaid_cards"] == []
assert result["agreements"] == []
def test_settlement_choices_use_authoritative_case_customer_for_prepaid_card(monkeypatch):
card_query = {}
monkeypatch.setattr(
timetracking_router,
"execute_query_single",
lambda query, params: {"customer_id": 904} if "FROM sag_sager" in query else None,
)
def fake_execute_query(query, params):
if "FROM tticket_prepaid_cards" in query:
card_query["customer_id"] = params[0]
return [{"id": 72, "card_number": "CARD-TEST", "remaining_hours": 4.0}]
if "FROM customer_fixed_price_agreements" in query:
return []
raise AssertionError(query)
monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query)
options = timetracking_router._settlement_options_for_case_entry(
{"id": 12, "sag_id": 55, "customer_id": 3}
)
assert card_query["customer_id"] == 904
assert options["prepaid_cards"][0]["id"] == 72
assert options["recommended"]["method"] == "prepaid"
def test_patch_time_entry_refuses_settled_rows_without_changing_them(monkeypatch):
monkeypatch.setattr(
timetracking_router,
"execute_query_single",
lambda *_args: {
"id": 41,
"status": "billed",
"description": "Original",
"prepaid_card_id": None,
"economy_settled_at": None,
"economy_order_draft_id": None,
"billed_via_thehub_id": None,
},
)
monkeypatch.setattr(
timetracking_router,
"execute_query",
lambda *_args: (_ for _ in ()).throw(AssertionError("settled rows must not be updated")),
)
try:
asyncio.run(timetracking_router.patch_time_entry_v1(41, {"description": "Changed"}))
except Exception as exc:
assert getattr(exc, "status_code", None) == 409
assert "korrektion" in str(getattr(exc, "detail", "")).lower()
else:
raise AssertionError("settled time entry unexpectedly accepted an edit")
def test_patch_time_entry_logs_before_after_for_editable_rows(monkeypatch):
existing = {
"id": 42,
"status": "pending",
"description": "Before",
"prepaid_card_id": None,
"economy_settled_at": None,
"economy_order_draft_id": None,
"billed_via_thehub_id": None,
}
updated = {**existing, "description": "After"}
monkeypatch.setattr(timetracking_router, "execute_query_single", lambda *_args: existing)
monkeypatch.setattr(timetracking_router, "execute_query", lambda *_args: [updated])
captured = {}
monkeypatch.setattr(timetracking_router.audit, "log_event", lambda **kwargs: captured.update(kwargs) or 5)
result = asyncio.run(
timetracking_router.patch_time_entry_v1(
42,
{"description": "After", "reason": "Rettelse"},
current_user={"id": 9},
)
)
assert result["description"] == "After"
assert captured["event_type"] == "time_entry_updated"
assert captured["user_id"] == 9
assert captured["details"]["changes"]["description"] == {"before": "Before", "after": "After"}
assert captured["details"]["reason"] == "Rettelse"
def test_case_time_form_sends_local_time_and_multiple_employees():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
manual_form_script = template.split("async function createManualTimeV1(event)", 1)[1].split(