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.
This commit is contained in:
parent
81b8229db1
commit
28a92eeb55
@ -181,6 +181,8 @@ class Settings(BaseSettings):
|
|||||||
VTIGER_URL: str = ""
|
VTIGER_URL: str = ""
|
||||||
VTIGER_USERNAME: str = ""
|
VTIGER_USERNAME: str = ""
|
||||||
VTIGER_API_KEY: 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
|
# Data Consistency Settings
|
||||||
VTIGER_SYNC_ENABLED: bool = True
|
VTIGER_SYNC_ENABLED: bool = True
|
||||||
|
|||||||
@ -110,6 +110,7 @@ def _hours_for_prepaid_card(row: Dict[str, Any], rounding_minutes: int) -> float
|
|||||||
async def list_hub_time_queue(
|
async def list_hub_time_queue(
|
||||||
customer_id: Optional[int] = Query(None, gt=0),
|
customer_id: Optional[int] = Query(None, gt=0),
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
|
case_state: Optional[str] = Query(None),
|
||||||
billable: Optional[bool] = Query(None),
|
billable: Optional[bool] = Query(None),
|
||||||
q: Optional[str] = Query(None),
|
q: Optional[str] = Query(None),
|
||||||
limit: int = Query(500, ge=1, le=2000),
|
limit: int = Query(500, ge=1, le=2000),
|
||||||
@ -134,6 +135,15 @@ async def list_hub_time_queue(
|
|||||||
conditions.append("t.status = %s")
|
conditions.append("t.status = %s")
|
||||||
params.append(status)
|
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:
|
if billable is not None:
|
||||||
conditions.append("COALESCE(t.billable, true) = %s")
|
conditions.append("COALESCE(t.billable, true) = %s")
|
||||||
params.append(billable)
|
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.original_hours, t.approved_hours, t.faktisk_tid_min,
|
||||||
t.fakturerbar_tid_min, t.round_block_min, t.worked_date, t.description,
|
t.fakturerbar_tid_min, t.round_block_min, t.worked_date, t.description,
|
||||||
COALESCE(c.title, s.titel, 'Tidsregistrering') AS case_title,
|
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(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 hub_customer_id,
|
||||||
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS billing_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 = {
|
item = {
|
||||||
"id": int(row["id"]),
|
"id": int(row["id"]),
|
||||||
"title": row.get("case_title") or "Tidsregistrering",
|
"title": row.get("case_title") or "Tidsregistrering",
|
||||||
|
"case_status": row.get("case_status"),
|
||||||
"customer_name": row.get("customer_name") or "Ukendt kunde",
|
"customer_name": row.get("customer_name") or "Ukendt kunde",
|
||||||
"hours": hours,
|
"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": method,
|
||||||
"method_label": _settlement_label(method),
|
"method_label": _settlement_label(method),
|
||||||
"valid": True,
|
"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",
|
"SELECT name, COALESCE(standard_hourly_rate, %s) AS hourly_rate FROM customers WHERE id = %s",
|
||||||
(settings.TIMETRACKING_DEFAULT_HOURLY_RATE, customer_id),
|
(settings.TIMETRACKING_DEFAULT_HOURLY_RATE, customer_id),
|
||||||
) or {}
|
) 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 = 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
|
for row in group_rows
|
||||||
)
|
)
|
||||||
rate = float(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE)
|
rate = float(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE)
|
||||||
|
|||||||
@ -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}}
|
@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}
|
.queue-actions { position:sticky;bottom:1rem;z-index:20;box-shadow:0 .5rem 1.5rem rgba(27,56,84,.16) }.preview-group{border-left:4px solid #0d6efd}.empty-state{border:2px dashed #d7e0e8;border-radius:.9rem}
|
||||||
.case-detail-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}.case-detail-meta div{padding:.65rem .75rem;background:#f6f8fa;border-radius:.55rem}.case-detail-meta small{display:block;color:#6c757d;margin-bottom:.12rem}@media(max-width:576px){.case-detail-meta{grid-template-columns:1fr}}
|
.case-detail-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}.case-detail-meta div{padding:.65rem .75rem;background:#f6f8fa;border-radius:.55rem}.case-detail-meta small{display:block;color:#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>
|
</style>
|
||||||
<main class="container-fluid py-4 time-queue">
|
<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 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>
|
<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="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">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="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.</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 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">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 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>
|
<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>
|
<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>
|
<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}
|
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 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 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 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 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 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 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 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 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 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($('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 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 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 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 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 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>`}}
|
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 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'}}
|
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()}
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -2,13 +2,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
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 decimal import Decimal
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from psycopg2.extras import Json, RealDictCursor
|
from psycopg2.extras import Json, RealDictCursor
|
||||||
|
|
||||||
from app.core.auth_dependencies import require_any_permission, require_permission
|
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)
|
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]:
|
def _safe_date(value: Any) -> Optional[date]:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
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")
|
@router.post("/items/{item_id}/rematch")
|
||||||
async def rematch(
|
async def rematch(
|
||||||
item_id: int, request: Request,
|
item_id: int, request: Request,
|
||||||
|
|||||||
@ -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="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="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="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>
|
</ul>
|
||||||
|
|
||||||
<section id="queuePanel" class="card mc-stat mc-queue mb-4 d-none">
|
<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="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>
|
<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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -118,6 +195,8 @@
|
|||||||
<script>
|
<script>
|
||||||
const API='/api/v1/migration-center';
|
const API='/api/v1/migration-center';
|
||||||
let sessions=[], sessionId=null, page=1, total=0, view='all', currentItem=null, pendingPreflight=null;
|
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 modal=()=>bootstrap.Modal.getOrCreateInstance(document.getElementById('actionModal'));
|
||||||
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
async function api(path, options={}){
|
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}`))}
|
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();
|
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){
|
function badge(value){
|
||||||
const green=['created_in_hub','linked_to_existing','verified','approved','locked','match_found'];
|
const green=['created_in_hub','linked_to_existing','verified','approved','locked','match_found'];
|
||||||
const red=['conflict','source_changed','lock_failed','no_match','rejected'];
|
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('');
|
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'));
|
const remembered=Number(localStorage.getItem('migrationCenterSession'));
|
||||||
sessionId=(sessions.find(s=>s.id===remembered)||sessions[0])?.id||null;
|
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()};
|
select.value=sessionId; select.onchange=()=>{sessionId=Number(select.value);localStorage.setItem('migrationCenterSession',sessionId);refresh()};
|
||||||
document.getElementById('workspace').classList.remove('d-none');
|
document.getElementById('workspace').classList.remove('d-none');
|
||||||
await refresh();
|
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('queuePanel').classList.toggle('d-none',view!=='queue');
|
||||||
document.getElementById('auditPanel').classList.toggle('d-none',view!=='audit');
|
document.getElementById('auditPanel').classList.toggle('d-none',view!=='audit');
|
||||||
document.getElementById('historyPanel').classList.toggle('d-none',!['invoice_history','subscription_candidates'].includes(view));
|
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);
|
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('prevPage').onclick=()=>loadItems(page-1);document.getElementById('nextPage').onclick=()=>loadItems(page+1);
|
||||||
document.getElementById('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter')loadItems(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>`});
|
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>`});
|
||||||
|
|||||||
@ -4072,15 +4072,143 @@ async def get_case_linked_activities(sag_id: int):
|
|||||||
return {"activities": activities, "total": len(activities)}
|
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")
|
@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."""
|
"""Return a unified timeline for the case with optional child cases."""
|
||||||
try:
|
try:
|
||||||
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
||||||
if not check:
|
if not check:
|
||||||
raise HTTPException(status_code=404, detail="Case not found")
|
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 = """
|
case_tree_query = """
|
||||||
WITH RECURSIVE normalized_relations AS (
|
WITH RECURSIVE normalized_relations AS (
|
||||||
SELECT
|
SELECT
|
||||||
@ -4112,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 []
|
case_rows = execute_query(case_tree_query, (sag_id,)) or []
|
||||||
else:
|
else:
|
||||||
|
tree_truncated = False
|
||||||
case_rows = execute_query(
|
case_rows = execute_query(
|
||||||
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||||
(sag_id,)
|
(sag_id,)
|
||||||
@ -4331,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), ''),
|
COALESCE(NULLIF(TRIM(recorder.full_name), ''), NULLIF(TRIM(recorder.username), ''),
|
||||||
NULLIF(TRIM(t.user_name), ''), 'System') AS actor_name,
|
NULLIF(TRIM(t.user_name), ''), 'System') AS actor_name,
|
||||||
COALESCE(NULLIF(TRIM(employee.full_name), ''), NULLIF(TRIM(employee.username), ''),
|
COALESCE(NULLIF(TRIM(employee.full_name), ''), NULLIF(TRIM(employee.username), ''),
|
||||||
|
NULLIF(TRIM(t.user_name), ''),
|
||||||
CONCAT('Medarbejder #', t.medarbejder_id::text)) AS employee_name,
|
CONCAT('Medarbejder #', t.medarbejder_id::text)) AS employee_name,
|
||||||
s.titel AS source_sag_titel
|
s.titel AS source_sag_titel
|
||||||
FROM tmodule_times t
|
FROM tmodule_times t
|
||||||
@ -4440,12 +4570,64 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return datetime.min
|
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 {
|
return {
|
||||||
"case_tree": case_rows,
|
"case_tree": case_rows,
|
||||||
"events": events,
|
"events": events,
|
||||||
"total": len(events),
|
"total": total,
|
||||||
|
"next_cursor": next_cursor,
|
||||||
|
"has_more": has_more,
|
||||||
|
"truncated": tree_truncated,
|
||||||
}
|
}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@ -1290,6 +1290,41 @@
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.case-relation-explorer { --rt-accent: #126b82; border: 1px solid rgba(18,107,130,.14); border-radius: 20px; overflow: hidden; background: var(--bg-card,#fff); box-shadow: 0 14px 38px rgba(28,61,78,.08); }
|
||||||
|
.case-relation-hero { display:flex; justify-content:space-between; align-items:center; gap:1rem; padding:1.5rem 1.65rem 1.3rem; color:#fff; background:radial-gradient(circle at 88% 8%,rgba(255,255,255,.2),transparent 24%),linear-gradient(118deg,#123c54,#126b82 58%,#2294a3); }
|
||||||
|
.case-relation-kicker { font-size:.68rem; letter-spacing:.16em; font-weight:800; opacity:.76; }
|
||||||
|
.case-relation-hero h2 { margin:.25rem 0; font-size:1.35rem; font-weight:760; }
|
||||||
|
.case-relation-hero p { margin:0; color:rgba(255,255,255,.78); font-size:.88rem; }
|
||||||
|
.case-relation-status { font-size:.78rem; color:rgba(255,255,255,.82); text-align:right; }
|
||||||
|
.case-relation-toolbar { display:flex; align-items:end; gap:.7rem; flex-wrap:wrap; padding:1rem 1.2rem; border-bottom:1px solid var(--border-color,#e4eaed); background:color-mix(in srgb,var(--accent,#126b82) 3%,var(--bg-card,#fff)); }
|
||||||
|
.case-relation-toolbar>label:not(.case-relation-search) { display:grid; gap:.25rem; min-width:145px; font-size:.7rem; color:var(--text-secondary,#657783); font-weight:700; }
|
||||||
|
.case-relation-search { position:relative; flex:1 1 280px; }
|
||||||
|
.case-relation-search i { position:absolute; left:.8rem; top:50%; transform:translateY(-50%); color:#78909a; }
|
||||||
|
.case-relation-search input { width:100%; min-height:36px; border:1px solid var(--border-color,#dce5e9); border-radius:9px; padding:.45rem .7rem .45rem 2.25rem; background:var(--bg-card,#fff); color:var(--text-primary,#23343d); }
|
||||||
|
.case-relation-summary { display:flex; justify-content:space-between; gap:.75rem; padding:.65rem 1.25rem; color:var(--text-secondary,#6b7c86); font-size:.74rem; border-bottom:1px solid var(--border-color,#edf0f2); }
|
||||||
|
.case-relation-tree { padding:1.1rem 1.2rem 1.4rem; overflow:auto; }
|
||||||
|
.relation-tree-branch { margin-left:1.1rem; padding-left:1.05rem; border-left:1px solid #d7e3e8; }
|
||||||
|
.relation-tree-node { position:relative; margin:.45rem 0; }
|
||||||
|
.relation-tree-node:before { content:""; position:absolute; top:1.05rem; left:-1.1rem; width:.8rem; border-top:1px solid #d7e3e8; }
|
||||||
|
.relation-tree-node-card { border:1px solid #e2eaed; border-radius:13px; background:var(--bg-card,#fff); box-shadow:0 3px 12px rgba(28,61,78,.045); transition:border-color .16s,box-shadow .16s,transform .16s; }
|
||||||
|
.relation-tree-node-card:hover { border-color:#acd0d8; box-shadow:0 8px 22px rgba(28,61,78,.09); transform:translateY(-1px); }
|
||||||
|
.relation-tree-node-card.is-current { border-color:#57aab4; background:linear-gradient(110deg,rgba(27,142,154,.075),var(--bg-card,#fff) 60%); box-shadow:0 0 0 2px rgba(27,142,154,.08); }
|
||||||
|
.relation-tree-node-main { display:flex; align-items:center; gap:.65rem; padding:.75rem .85rem; }
|
||||||
|
.relation-tree-node-icon { display:grid; place-items:center; width:34px; height:34px; flex:0 0 34px; border-radius:10px; background:#edf6f7; color:#187487; font-size:1rem; }
|
||||||
|
.relation-tree-node-card.is-current .relation-tree-node-icon { background:#187487; color:#fff; }
|
||||||
|
.relation-tree-node-info { min-width:0; flex:1; }
|
||||||
|
.relation-tree-title { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:720; color:var(--text-primary,#263c47); text-decoration:none; }
|
||||||
|
.relation-tree-meta { display:flex; flex-wrap:wrap; gap:.35rem .6rem; margin-top:.18rem; color:var(--text-secondary,#71828b); font-size:.72rem; }
|
||||||
|
.relation-tree-node-actions { display:flex; align-items:center; gap:.4rem; }
|
||||||
|
.relation-tree-relation { border-radius:99px; padding:.22rem .55rem; background:#f0f4f6; color:#50636d; font-size:.68rem; font-weight:700; white-space:nowrap; }
|
||||||
|
.relation-tree-module-panel { padding:.15rem .85rem .85rem 3.45rem; }
|
||||||
|
.relation-tree-module-content { margin-top:.65rem; max-height:260px; overflow:auto; border:1px solid #e8eef0; border-radius:10px; padding:.65rem; background:color-mix(in srgb,var(--accent,#126b82) 2%,var(--bg-card,#fff)); }
|
||||||
|
.relation-tree-module-row { padding:.45rem 0; border-bottom:1px solid #e8eef0; font-size:.78rem; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||||
|
.relation-tree-module-row:last-child { border-bottom:0; }
|
||||||
|
[data-bs-theme="dark"] .case-relation-explorer { border-color:#334650; }
|
||||||
|
[data-bs-theme="dark"] .relation-tree-node-card { border-color:#334650; }
|
||||||
|
@media(max-width:700px) { .case-relation-hero { align-items:flex-start; flex-direction:column; padding:1.1rem; } .case-relation-status { text-align:left; } .case-relation-toolbar { padding:.8rem; } .case-relation-summary { flex-direction:column; } .relation-tree-node-main { align-items:flex-start; flex-wrap:wrap; } .relation-tree-node-actions { width:100%; justify-content:flex-end; } .relation-tree-node-actions .relation-tree-relation { margin-right:auto; } .relation-tree-module-panel { padding-left:.8rem; } }
|
||||||
|
|
||||||
.history-timeline-shell {
|
.history-timeline-shell {
|
||||||
border: 1px solid rgba(15, 76, 117, 0.18);
|
border: 1px solid rgba(15, 76, 117, 0.18);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@ -4904,6 +4939,11 @@
|
|||||||
<span class="case-tab-count-badge" id="historyTabCountBadge"></span>
|
<span class="case-tab-count-badge" id="historyTabCountBadge"></span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="relations-tree-tab" data-bs-target="#relations-tree" type="button" role="tab" onclick="return activateCaseTabFromButton(event, 'relations-tree')">
|
||||||
|
<i class="bi bi-diagram-3 me-2"></i>Relations-træ
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div id="timeActiveBanner" class="alert alert-warning d-none justify-content-between align-items-center mb-3" role="alert">
|
<div id="timeActiveBanner" class="alert alert-warning d-none justify-content-between align-items-center mb-3" role="alert">
|
||||||
@ -4914,7 +4954,7 @@
|
|||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button id="timeV1PauseButton" class="btn btn-sm btn-outline-secondary" onclick="pauseLiveTimerV1()"><i class="bi bi-pause-fill me-1"></i>Pause</button>
|
<button id="timeV1PauseButton" class="btn btn-sm btn-outline-secondary" onclick="pauseLiveTimerV1()"><i class="bi bi-pause-fill me-1"></i>Pause</button>
|
||||||
<button id="timeV1ResumeButton" class="btn btn-sm btn-outline-primary d-none" onclick="resumeCaseTimerV1()"><i class="bi bi-play-fill me-1"></i>Genoptag</button>
|
<button id="timeV1ResumeButton" class="btn btn-sm btn-outline-primary d-none" onclick="resumeCaseTimerV1()"><i class="bi bi-play-fill me-1"></i>Genoptag</button>
|
||||||
<button id="timeV1BannerStopButton" class="btn btn-sm btn-danger" onclick="stopLiveTimerV1({ entry_status: 'afventer' })"><i class="bi bi-stop-fill me-1"></i>Stop</button>
|
<button id="timeV1BannerStopButton" class="btn btn-sm btn-danger" onclick="stopLiveTimerV1({ entry_status: 'kladde' })"><i class="bi bi-stop-fill me-1"></i>Stop</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -10090,7 +10130,7 @@
|
|||||||
<button id="timeV1StartButton" class="btn btn-sm btn-primary" onclick="startLiveTimerV1()">
|
<button id="timeV1StartButton" class="btn btn-sm btn-primary" onclick="startLiveTimerV1()">
|
||||||
<i class="bi bi-play-fill me-1"></i>Start timer
|
<i class="bi bi-play-fill me-1"></i>Start timer
|
||||||
</button>
|
</button>
|
||||||
<button id="timeV1StopButton" class="btn btn-sm btn-outline-danger" onclick="stopLiveTimerV1({ entry_status: 'afventer' })" disabled>
|
<button id="timeV1StopButton" class="btn btn-sm btn-outline-danger" onclick="stopLiveTimerV1({ entry_status: 'kladde' })" disabled>
|
||||||
<i class="bi bi-stop-fill me-1"></i>Stop timer
|
<i class="bi bi-stop-fill me-1"></i>Stop timer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -10922,43 +10962,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="relations-tree" role="tabpanel" tabindex="0">
|
||||||
|
<section class="case-relation-explorer" aria-labelledby="caseTreeHeading">
|
||||||
|
<div class="case-relation-hero">
|
||||||
|
<div><div class="case-relation-kicker">SAGERNES SAMMENHÆNG</div><h2 id="caseTreeHeading">Relations-træ</h2><p>Find forbindelserne, og åbn modulindhold direkte på hver sag.</p></div>
|
||||||
|
<div class="case-relation-status" id="caseTreeStatus" aria-live="polite">Åbn fanen for at indlæse relationer.</div>
|
||||||
|
</div>
|
||||||
|
<div class="case-relation-toolbar" role="search">
|
||||||
|
<label class="case-relation-search"><i class="bi bi-search" aria-hidden="true"></i><span class="visually-hidden">Søg i relationstræ</span><input id="relationTreeSearch" type="search" placeholder="Søg på sag, titel eller relation…" autocomplete="off"></label>
|
||||||
|
<label><span>Relation</span><select id="relationTreeType" class="form-select form-select-sm"><option value="">Alle relationer</option></select></label>
|
||||||
|
<label><span>Placering</span><select id="relationTreePosition" class="form-select form-select-sm"><option value="">Hele træet</option><option value="parent">Overliggende sager</option><option value="current">Aktuel sag</option><option value="child">Undersager</option></select></label>
|
||||||
|
<label><span>Status</span><select id="relationTreeStatusFilter" class="form-select form-select-sm"><option value="">Alle statusser</option></select></label>
|
||||||
|
<button type="button" id="relationTreeReset" class="btn btn-sm btn-light border"><i class="bi bi-arrow-counterclockwise me-1"></i>Nulstil</button>
|
||||||
|
</div>
|
||||||
|
<div class="case-relation-summary"><span id="relationTreeCount">Sager indlæses…</span><span><i class="bi bi-info-circle me-1"></i>Vælg “Se modulindhold” på en sag for detaljer</span></div>
|
||||||
|
<div id="caseTreeContent" class="case-relation-tree" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-pane fade" id="history" role="tabpanel" tabindex="0" data-module="history" data-has-content="true">
|
<div class="tab-pane fade" id="history" role="tabpanel" tabindex="0" data-module="history" data-has-content="true">
|
||||||
<div class="history-timeline-shell">
|
<div class="history-timeline-shell">
|
||||||
<div class="history-timeline-toolbar">
|
<div class="history-timeline-toolbar">
|
||||||
<label class="form-check-label small d-inline-flex align-items-center gap-2 me-2">
|
<label class="small" for="historyRelationFilter">Sag i træet</label>
|
||||||
<input class="form-check-input mt-0" type="checkbox" id="historyIncludeSubcasesToggle">
|
<select id="historyRelationFilter" class="form-select form-select-sm" aria-label="Filtrér på sagens relation" style="max-width:180px"><option value="">Alle relationer</option><option value="parent">Overliggende sag</option><option value="current">Aktuel sag</option><option value="child">Undersager</option></select>
|
||||||
Inkluder undersager
|
<label class="small" for="historyEventFilter">Hændelsestype</label>
|
||||||
</label>
|
<select id="historyEventFilter" class="form-select form-select-sm" aria-label="Filtrér på hændelsestype" style="max-width:180px"><option value="">Alle typer</option><option value="comment">Kommentarer</option><option value="field_change">Feltændringer</option><option value="description">Beskrivelse</option><option value="reminder">Påmindelser</option><option value="time">Tidsforbrug</option><option value="case">Sag</option><option value="tag">Tags</option><option value="todo">Opgaver</option><option value="relation">Relationer</option><option value="call">Opkald</option><option value="anydesk">AnyDesk</option></select>
|
||||||
<input id="historyTimelineSearch" type="text" class="form-control form-control-sm" placeholder="Søg i historik..." style="max-width: 260px;">
|
<label class="visually-hidden" for="historyTimelineSearch">Søg i historik</label>
|
||||||
<label class="form-check-label small d-inline-flex align-items-center gap-1">
|
<input id="historyTimelineSearch" type="search" class="form-control form-control-sm" placeholder="Søg i historik..." style="max-width:220px">
|
||||||
<input class="form-check-input mt-0" type="checkbox" id="historyFilterComments" checked>
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="historyTimelineApply">Søg</button>
|
||||||
Kommentarer
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="historyTimelineReset">Nulstil</button>
|
||||||
</label>
|
|
||||||
<label class="form-check-label small d-inline-flex align-items-center gap-1">
|
|
||||||
<input class="form-check-input mt-0" type="checkbox" id="historyFilterDescription" checked>
|
|
||||||
Beskrivelse
|
|
||||||
</label>
|
|
||||||
<label class="form-check-label small d-inline-flex align-items-center gap-1">
|
|
||||||
<input class="form-check-input mt-0" type="checkbox" id="historyFilterReminders" checked>
|
|
||||||
Påmindelser
|
|
||||||
</label>
|
|
||||||
<label class="form-check-label small d-inline-flex align-items-center gap-1">
|
|
||||||
<input class="form-check-input mt-0" type="checkbox" id="historyFilterTime" checked>
|
|
||||||
Tidsforbrug
|
|
||||||
</label>
|
|
||||||
<label class="form-check-label small d-inline-flex align-items-center gap-1">
|
|
||||||
<input class="form-check-input mt-0" type="checkbox" id="historyFilterFieldChanges" checked>
|
|
||||||
Feltændringer
|
|
||||||
</label>
|
|
||||||
<label class="form-check-label small d-inline-flex align-items-center gap-1">
|
|
||||||
<input class="form-check-input mt-0" type="checkbox" id="historyFilterCaseActivity" checked>
|
|
||||||
Sagsaktivitet
|
|
||||||
</label>
|
|
||||||
<span class="ms-auto badge text-bg-light" id="historyTimelineCount">0</span>
|
<span class="ms-auto badge text-bg-light" id="historyTimelineCount">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="history-timeline-list" id="historyTimelineList">
|
<div class="history-timeline-list" id="historyTimelineList">
|
||||||
<div class="text-muted text-center py-4">Åbn fanen for at indlæse historik...</div>
|
<div class="text-muted text-center py-4">Åbn fanen for at indlæse historik...</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-center p-3"><button type="button" class="btn btn-sm btn-outline-primary d-none" id="historyTimelineMore">Vis flere hændelser</button></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -12368,34 +12406,7 @@
|
|||||||
const list = document.getElementById('historyTimelineList');
|
const list = document.getElementById('historyTimelineList');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
const search = String(document.getElementById('historyTimelineSearch')?.value || '').trim().toLowerCase();
|
const filtered = caseHistoryTimelineCache || [];
|
||||||
const includeComments = document.getElementById('historyFilterComments')?.checked !== false;
|
|
||||||
const includeDescription = document.getElementById('historyFilterDescription')?.checked !== false;
|
|
||||||
const includeReminders = document.getElementById('historyFilterReminders')?.checked !== false;
|
|
||||||
const includeTime = document.getElementById('historyFilterTime')?.checked !== false;
|
|
||||||
const includeFieldChanges = document.getElementById('historyFilterFieldChanges')?.checked !== false;
|
|
||||||
const includeCaseActivity = document.getElementById('historyFilterCaseActivity')?.checked !== false;
|
|
||||||
|
|
||||||
const filtered = (caseHistoryTimelineCache || []).filter((event) => {
|
|
||||||
if (event.event_type === 'comment' && !includeComments) return false;
|
|
||||||
if (event.event_type === 'description' && !includeDescription) return false;
|
|
||||||
if (event.event_type === 'reminder' && !includeReminders) return false;
|
|
||||||
if (event.event_type === 'time' && !includeTime) return false;
|
|
||||||
if (event.event_type === 'field_change' && !includeFieldChanges) return false;
|
|
||||||
if (['case', 'tag', 'todo', 'relation'].includes(event.event_type) && !includeCaseActivity) return false;
|
|
||||||
|
|
||||||
if (!search) return true;
|
|
||||||
|
|
||||||
const haystack = [
|
|
||||||
event.title,
|
|
||||||
event.description,
|
|
||||||
event.sag_titel,
|
|
||||||
event.forfatter,
|
|
||||||
event.source,
|
|
||||||
].join(' ').toLowerCase();
|
|
||||||
|
|
||||||
return haystack.includes(search);
|
|
||||||
});
|
|
||||||
|
|
||||||
const counter = document.getElementById('historyTimelineCount');
|
const counter = document.getElementById('historyTimelineCount');
|
||||||
if (counter) counter.textContent = `${filtered.length}`;
|
if (counter) counter.textContent = `${filtered.length}`;
|
||||||
@ -12409,6 +12420,7 @@
|
|||||||
const icon = _historyIconFor(event);
|
const icon = _historyIconFor(event);
|
||||||
const ts = _historyFormatTime(event.timestamp);
|
const ts = _historyFormatTime(event.timestamp);
|
||||||
const caseMeta = event.sag_id ? `Sag #${event.sag_id}${event.sag_titel ? ` · ${_historyEscape(event.sag_titel)}` : ''}` : '';
|
const caseMeta = event.sag_id ? `Sag #${event.sag_id}${event.sag_titel ? ` · ${_historyEscape(event.sag_titel)}` : ''}` : '';
|
||||||
|
const relationLabel = ({parent:'Overliggende sag',current:'Aktuel sag',child:'Undersag'})[event.relation_type] || '';
|
||||||
const sourceBadge = event.source ? `<span class="badge text-bg-light border">${_historyEscape(event.source)}</span>` : '';
|
const sourceBadge = event.source ? `<span class="badge text-bg-light border">${_historyEscape(event.source)}</span>` : '';
|
||||||
const isFieldChange = (event?.event_type || '') === 'field_change';
|
const isFieldChange = (event?.event_type || '') === 'field_change';
|
||||||
const subtypeBadge = event.event_subtype
|
const subtypeBadge = event.event_subtype
|
||||||
@ -12422,6 +12434,7 @@
|
|||||||
<i class="bi ${icon}"></i>
|
<i class="bi ${icon}"></i>
|
||||||
<span><strong>Hvornår:</strong> ${ts}</span>
|
<span><strong>Hvornår:</strong> ${ts}</span>
|
||||||
<span><strong>Hvem:</strong> ${_historyEscape(event.forfatter || 'System')}</span>
|
<span><strong>Hvem:</strong> ${_historyEscape(event.forfatter || 'System')}</span>
|
||||||
|
${relationLabel ? `<span class="badge text-bg-light border">${relationLabel}</span>` : ''}
|
||||||
${sourceBadge}
|
${sourceBadge}
|
||||||
${subtypeBadge}
|
${subtypeBadge}
|
||||||
${caseMeta ? `<span class="text-muted">${caseMeta}</span>` : ''}
|
${caseMeta ? `<span class="text-muted">${caseMeta}</span>` : ''}
|
||||||
@ -12433,25 +12446,120 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCaseHistoryTimeline(forceReload = false) {
|
let historyNextCursor = null;
|
||||||
const includeSubcases = !!document.getElementById('historyIncludeSubcasesToggle')?.checked;
|
const HISTORY_PAGE_SIZE = 40;
|
||||||
const loadKey = includeSubcases ? 'with_subcases' : 'case_only';
|
function historyQuery(useCursor = false) {
|
||||||
const list = document.getElementById('historyTimelineList');
|
const params = new URLSearchParams({whole_tree:'true', limit:String(HISTORY_PAGE_SIZE)});
|
||||||
if (!list) return;
|
const relation = document.getElementById('historyRelationFilter')?.value;
|
||||||
|
const eventType = document.getElementById('historyEventFilter')?.value;
|
||||||
if (!forceReload && caseHistoryTimelineLoadedKey === loadKey) {
|
const search = String(document.getElementById('historyTimelineSearch')?.value || '').trim();
|
||||||
renderCaseHistoryTimeline();
|
if (relation) params.set('relation_types', relation);
|
||||||
|
if (eventType) params.set('event_types', eventType);
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
if (useCursor && historyNextCursor) params.set('cursor', historyNextCursor);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
function collectRelationTreeNodes(nodes, result = []) {
|
||||||
|
(nodes || []).forEach(node => { result.push(node); collectRelationTreeNodes(node.children, result); });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function relationTreeNodeMatches(node) {
|
||||||
|
const search = String(document.getElementById('relationTreeSearch')?.value || '').trim().toLocaleLowerCase('da-DK');
|
||||||
|
const relation = document.getElementById('relationTreeType')?.value || '';
|
||||||
|
const position = document.getElementById('relationTreePosition')?.value || '';
|
||||||
|
const status = document.getElementById('relationTreeStatusFilter')?.value || '';
|
||||||
|
const relationLabel = String(node.relation_type || (node.is_current ? 'aktuel sag' : '')).toLocaleLowerCase('da-DK');
|
||||||
|
const positionMatch = !position || node.relation === position;
|
||||||
|
const relationMatch = !relation || relationLabel === relation.toLocaleLowerCase('da-DK');
|
||||||
|
const statusMatch = !status || String(node.status || '').toLocaleLowerCase('da-DK') === status.toLocaleLowerCase('da-DK');
|
||||||
|
const searchMatch = !search || [node.id, node.title, node.status, node.type, node.relation_type].join(' ').toLocaleLowerCase('da-DK').includes(search);
|
||||||
|
return positionMatch && relationMatch && statusMatch && searchMatch;
|
||||||
|
}
|
||||||
|
function renderRelationTreeNode(node) {
|
||||||
|
const ownMatch = relationTreeNodeMatches(node);
|
||||||
|
const renderedChildren = (node.children || []).map(renderRelationTreeNode).filter(Boolean);
|
||||||
|
if (!ownMatch && !renderedChildren.length) return '';
|
||||||
|
const current = !!node.is_current;
|
||||||
|
const relationLabel = current ? 'Aktuel sag' : (node.relation_type || (node.relation === 'parent' ? 'Overliggende sag' : 'Undersag'));
|
||||||
|
const statusLabel = node.status || 'Ukendt status';
|
||||||
|
const typeLabel = node.type || 'Sag';
|
||||||
|
const safeId = Number(node.id);
|
||||||
|
const cardClass = `relation-tree-node-card${current ? ' is-current' : ''}${ownMatch ? '' : ' opacity-50'}`;
|
||||||
|
return `<div class="relation-tree-node" data-case-id="${safeId}">
|
||||||
|
<article class="${cardClass}">
|
||||||
|
<div class="relation-tree-node-main">
|
||||||
|
<span class="relation-tree-node-icon"><i class="bi ${current?'bi-bullseye':'bi-file-earmark-text'}"></i></span>
|
||||||
|
<div class="relation-tree-node-info"><a class="relation-tree-title" ${current?'aria-current="page"':''} href="/sag/${encodeURIComponent(safeId)}/v4">#${safeId} · ${_historyEscape(node.title || `Sag #${safeId}`)}</a><div class="relation-tree-meta"><span>${_historyEscape(typeLabel)}</span><span>·</span><span>${_historyEscape(statusLabel)}</span></div></div>
|
||||||
|
<div class="relation-tree-node-actions"><span class="relation-tree-relation">${_historyEscape(relationLabel)}</span><button class="btn btn-sm btn-outline-primary" type="button" data-relation-modules="${safeId}" aria-expanded="false"><i class="bi bi-layout-text-window-reverse me-1"></i>Se modulindhold</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="relation-tree-module-panel d-none" id="relation-modules-${safeId}"><div class="d-flex flex-wrap gap-1" role="group" aria-label="Moduler for sag ${safeId}"><button class="btn btn-sm btn-light border" type="button" data-relation-module="comments" data-case-id="${safeId}">Kommentarer</button><button class="btn btn-sm btn-light border" type="button" data-relation-module="todos" data-case-id="${safeId}">Opgaver</button><button class="btn btn-sm btn-light border" type="button" data-relation-module="files" data-case-id="${safeId}">Filer</button><button class="btn btn-sm btn-light border" type="button" data-relation-module="relations" data-case-id="${safeId}">Relationer</button><a class="btn btn-sm btn-light border" href="/sag/${safeId}/v4"><i class="bi bi-box-arrow-up-right me-1"></i>Alle moduler</a></div><div class="relation-tree-module-content d-none" id="relation-module-content-${safeId}" aria-live="polite"></div></div>
|
||||||
|
</article>
|
||||||
|
${renderedChildren.length ? `<div class="relation-tree-branch">${renderedChildren.join('')}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function renderCaseTreeNodes(nodes) {
|
||||||
|
const tree = (nodes || []).map(renderRelationTreeNode).filter(Boolean).join('');
|
||||||
|
const count = collectRelationTreeNodes(nodes).filter(relationTreeNodeMatches).length;
|
||||||
|
const total = collectRelationTreeNodes(nodes).length;
|
||||||
|
document.getElementById('relationTreeCount').textContent = count === total ? `${total} sager i relationstræet` : `${count} af ${total} sager matcher filtrene`;
|
||||||
|
return tree || '<div class="text-center text-muted py-5"><i class="bi bi-search fs-3 d-block mb-2"></i>Ingen sager matcher dine filtre. Prøv at ændre søgningen eller nulstil filtrene.</div>';
|
||||||
|
}
|
||||||
|
function renderRelationModuleRows(module, items, caseId) {
|
||||||
|
if (!Array.isArray(items) || !items.length) return '<div class="text-muted small">Ingen indhold i dette modul.</div>';
|
||||||
|
const labels = {comments: item => `${item.created_at || ''} · ${item.forfatter || 'Bruger'}\n${item.indhold || ''}`, todos: item => `${item.is_done ? '✓' : '○'} ${item.title || 'Opgave'}${item.description ? `\n${item.description}` : ''}`, files: item => `${item.filename || 'Fil'} · ${item.size_bytes || 0} bytes`, relations: item => `Sag #${item.kilde_sag_id === caseId ? item.målsag_id : item.kilde_sag_id} · ${item.relationstype || 'Relation'}`};
|
||||||
|
const label = labels[module] || (item => JSON.stringify(item));
|
||||||
|
return items.map(item => `<div class="relation-tree-module-row">${_historyEscape(label(item))}</div>`).join('');
|
||||||
|
}
|
||||||
|
async function loadRelationModule(caseId, module, target) {
|
||||||
|
const route = {comments:'kommentarer',todos:'todo-steps',files:'files',relations:'relationer'}[module];
|
||||||
|
target.classList.remove('d-none');
|
||||||
|
target.innerHTML = '<span class="text-muted small"><span class="spinner-border spinner-border-sm me-1"></span>Henter indhold…</span>';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/sag/${caseId}/${route}`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const items = await response.json();
|
||||||
|
target.innerHTML = renderRelationModuleRows(module, items, caseId);
|
||||||
|
} catch (error) {
|
||||||
|
target.innerHTML = '<span class="text-danger small">Kunne ikke hente modulindhold.</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadCaseTree() {
|
||||||
|
const status = document.getElementById('caseTreeStatus');
|
||||||
|
const content = document.getElementById('caseTreeContent');
|
||||||
|
if (!content) return;
|
||||||
|
if (window.caseRelationTreeLoaded) {
|
||||||
|
content.innerHTML = renderCaseTreeNodes(window.caseRelationTreeData || []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = '<div class="text-muted text-center py-4"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser historik...</div>';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/sag/${caseIds}/timeline?include_subcases=${includeSubcases ? 'true' : 'false'}`);
|
const res = await fetch(`/api/v1/sag/${caseIds}/case-tree`);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const payload = await res.json();
|
const payload = await res.json();
|
||||||
caseHistoryTimelineCache = Array.isArray(payload?.events) ? payload.events : [];
|
window.caseRelationTreeData = payload.tree || [];
|
||||||
caseHistoryTimelineLoadedKey = loadKey;
|
const allNodes = collectRelationTreeNodes(window.caseRelationTreeData);
|
||||||
|
const relationSelect = document.getElementById('relationTreeType');
|
||||||
|
const statusSelect = document.getElementById('relationTreeStatusFilter');
|
||||||
|
relationSelect.length = 1;
|
||||||
|
statusSelect.length = 1;
|
||||||
|
[...new Set(allNodes.map(node => node.relation_type).filter(Boolean))].sort().forEach(value => relationSelect.add(new Option(value, value)));
|
||||||
|
[...new Set(allNodes.map(node => node.status).filter(Boolean))].sort().forEach(value => statusSelect.add(new Option(value, value)));
|
||||||
|
content.innerHTML = renderCaseTreeNodes(window.caseRelationTreeData);
|
||||||
|
window.caseRelationTreeLoaded = true;
|
||||||
|
status.textContent = payload.truncated ? 'Træet er afkortet af sikkerhedsgrænsen.' : '';
|
||||||
|
} catch (error) { status.textContent = 'Kunne ikke indlæse sagstræet.'; content.innerHTML = ''; }
|
||||||
|
}
|
||||||
|
async function loadCaseHistoryTimeline(reset = true) {
|
||||||
|
const list = document.getElementById('historyTimelineList');
|
||||||
|
if (!list) return;
|
||||||
|
if (reset) { historyNextCursor = null; caseHistoryTimelineCache = []; list.innerHTML = '<div class="text-muted text-center py-4"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser historik...</div>'; }
|
||||||
|
else if (!historyNextCursor) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/sag/${caseIds}/timeline?${historyQuery(!reset)}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const payload = await res.json();
|
||||||
|
caseHistoryTimelineCache = caseHistoryTimelineCache.concat(Array.isArray(payload?.events) ? payload.events : []);
|
||||||
|
historyNextCursor = payload?.next_cursor || null;
|
||||||
window.initialCaseTabCounts = Object.assign({}, window.initialCaseTabCounts || {}, { history: caseHistoryTimelineCache.length });
|
window.initialCaseTabCounts = Object.assign({}, window.initialCaseTabCounts || {}, { history: caseHistoryTimelineCache.length });
|
||||||
|
|
||||||
if (typeof _setCaseTabCountBadge === 'function') {
|
if (typeof _setCaseTabCountBadge === 'function') {
|
||||||
@ -12459,6 +12567,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderCaseHistoryTimeline();
|
renderCaseHistoryTimeline();
|
||||||
|
document.getElementById('historyTimelineMore')?.classList.toggle('d-none', !payload.has_more);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
list.innerHTML = '<div class="text-danger text-center py-4">Kunne ikke indlæse historik.</div>';
|
list.innerHTML = '<div class="text-danger text-center py-4">Kunne ikke indlæse historik.</div>';
|
||||||
console.error('Historik-timeline fejl:', error);
|
console.error('Historik-timeline fejl:', error);
|
||||||
@ -12468,24 +12577,44 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const historyTab = document.getElementById('history-tab');
|
const historyTab = document.getElementById('history-tab');
|
||||||
if (historyTab) {
|
if (historyTab) {
|
||||||
historyTab.addEventListener('shown.bs.tab', () => loadCaseHistoryTimeline(false));
|
historyTab.addEventListener('shown.bs.tab', () => loadCaseHistoryTimeline(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
loadCaseHistoryTimeline(false);
|
loadCaseHistoryTimeline(true);
|
||||||
|
|
||||||
const includeToggle = document.getElementById('historyIncludeSubcasesToggle');
|
document.getElementById('historyTimelineApply')?.addEventListener('click', () => loadCaseHistoryTimeline(true));
|
||||||
if (includeToggle) {
|
document.getElementById('historyTimelineReset')?.addEventListener('click', () => {
|
||||||
includeToggle.addEventListener('change', () => loadCaseHistoryTimeline(true));
|
document.getElementById('historyRelationFilter').value = '';
|
||||||
|
document.getElementById('historyEventFilter').value = '';
|
||||||
|
document.getElementById('historyTimelineSearch').value = '';
|
||||||
|
loadCaseHistoryTimeline(true);
|
||||||
|
});
|
||||||
|
['historyRelationFilter','historyEventFilter'].forEach(id => document.getElementById(id)?.addEventListener('change', () => loadCaseHistoryTimeline(true)));
|
||||||
|
document.getElementById('historyTimelineSearch')?.addEventListener('keydown', event => { if(event.key === 'Enter') loadCaseHistoryTimeline(true); });
|
||||||
|
document.getElementById('relations-tree-tab')?.addEventListener('shown.bs.tab', loadCaseTree);
|
||||||
|
['relationTreeSearch','relationTreeType','relationTreePosition','relationTreeStatusFilter'].forEach(id => document.getElementById(id)?.addEventListener(id === 'relationTreeSearch' ? 'input' : 'change', () => {
|
||||||
|
const target = document.getElementById('caseTreeContent');
|
||||||
|
target.innerHTML = renderCaseTreeNodes(window.caseRelationTreeData || []);
|
||||||
|
}));
|
||||||
|
document.getElementById('relationTreeReset')?.addEventListener('click', () => {
|
||||||
|
['relationTreeSearch','relationTreeType','relationTreePosition','relationTreeStatusFilter'].forEach(id => document.getElementById(id).value = '');
|
||||||
|
document.getElementById('caseTreeContent').innerHTML = renderCaseTreeNodes(window.caseRelationTreeData || []);
|
||||||
|
});
|
||||||
|
document.getElementById('caseTreeContent')?.addEventListener('click', event => {
|
||||||
|
const moduleToggle = event.target.closest('[data-relation-modules]');
|
||||||
|
if (moduleToggle) {
|
||||||
|
const caseId = Number(moduleToggle.dataset.relationModules);
|
||||||
|
const panel = document.getElementById(`relation-modules-${caseId}`);
|
||||||
|
const opening = panel.classList.contains('d-none');
|
||||||
|
panel.classList.toggle('d-none', !opening);
|
||||||
|
moduleToggle.setAttribute('aria-expanded', String(opening));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const moduleButton = event.target.closest('[data-relation-module]');
|
||||||
const searchInput = document.getElementById('historyTimelineSearch');
|
if (!moduleButton) return;
|
||||||
if (searchInput) {
|
const caseId = Number(moduleButton.dataset.caseId);
|
||||||
searchInput.addEventListener('input', renderCaseHistoryTimeline);
|
const target = document.getElementById(`relation-module-content-${caseId}`);
|
||||||
}
|
loadRelationModule(caseId, moduleButton.dataset.relationModule, target);
|
||||||
|
|
||||||
['historyFilterComments', 'historyFilterDescription', 'historyFilterReminders', 'historyFilterTime', 'historyFilterFieldChanges', 'historyFilterCaseActivity'].forEach((id) => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.addEventListener('change', renderCaseHistoryTimeline);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -13385,7 +13514,7 @@
|
|||||||
let actions = '';
|
let actions = '';
|
||||||
if (own && running) {
|
if (own && running) {
|
||||||
actions = `<button class="btn btn-sm btn-outline-warning" onclick="pauseLiveTimerV1()"><i class="bi bi-pause-fill me-1"></i>Pause</button>
|
actions = `<button class="btn btn-sm btn-outline-warning" onclick="pauseLiveTimerV1()"><i class="bi bi-pause-fill me-1"></i>Pause</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="stopLiveTimerV1({time_id:${id}, entry_status:'afventer'})"><i class="bi bi-stop-fill me-1"></i>Stop</button>`;
|
<button class="btn btn-sm btn-outline-danger" onclick="stopLiveTimerV1({time_id:${id}, entry_status:'kladde'})"><i class="bi bi-stop-fill me-1"></i>Stop</button>`;
|
||||||
} else if (own && paused) {
|
} else if (own && paused) {
|
||||||
actions = `<button class="btn btn-sm btn-primary" onclick="resumeCaseTimerByIdV1(${id})"><i class="bi bi-play-fill me-1"></i>Genoptag</button>`;
|
actions = `<button class="btn btn-sm btn-primary" onclick="resumeCaseTimerByIdV1(${id})"><i class="bi bi-play-fill me-1"></i>Genoptag</button>`;
|
||||||
} else if (own) {
|
} else if (own) {
|
||||||
|
|||||||
@ -166,6 +166,58 @@ class VTigerService:
|
|||||||
logger.error(f"❌ vTiger query error: {e}")
|
logger.error(f"❌ vTiger query error: {e}")
|
||||||
return []
|
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]]:
|
async def retrieve_file(self, resource_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Retrieve the original bytes for a vTiger file resource."""
|
"""Retrieve the original bytes for a vTiger file resource."""
|
||||||
safe_id = self._sanitize_vtiger_id(resource_id)
|
safe_id = self._sanitize_vtiger_id(resource_id)
|
||||||
@ -606,6 +658,49 @@ class VTigerService:
|
|||||||
logger.error(f"❌ Error updating subscription: {e}")
|
logger.error(f"❌ Error updating subscription: {e}")
|
||||||
raise
|
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]:
|
async def get_service_contracts(self, account_id: Optional[str] = None, active_only: bool = True) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Fetch service contracts from vTiger
|
Fetch service contracts from vTiger
|
||||||
|
|||||||
@ -244,6 +244,55 @@ def _settlement_options_for_customer(customer_id: Any) -> Dict[str, Any]:
|
|||||||
return {"recommended": recommendation, "prepaid_cards": cards, "agreements": agreements}
|
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]:
|
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)."""
|
"""Resolve tmodule customer_id for a case (tmodule_times FK target)."""
|
||||||
try:
|
try:
|
||||||
@ -3003,7 +3052,8 @@ async def create_manual_time_v1(
|
|||||||
@router.patch("/time/{time_id}", tags=["Internal"])
|
@router.patch("/time/{time_id}", tags=["Internal"])
|
||||||
async def patch_time_entry_v1(
|
async def patch_time_entry_v1(
|
||||||
time_id: int,
|
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."""
|
"""Patch udvalgte felter på tidsentry. Faktisk tid ændres kun via eksplicit brugerinput, aldrig automatisk."""
|
||||||
try:
|
try:
|
||||||
@ -3011,6 +3061,19 @@ async def patch_time_entry_v1(
|
|||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail="Time entry not found")
|
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)
|
_assert_prepaid_entry_editable(existing)
|
||||||
|
|
||||||
updates: Dict[str, Any] = {}
|
updates: Dict[str, Any] = {}
|
||||||
@ -3071,6 +3134,24 @@ async def patch_time_entry_v1(
|
|||||||
|
|
||||||
query = f"UPDATE tmodule_times SET {', '.join(set_parts)} WHERE id = %s RETURNING *"
|
query = f"UPDATE tmodule_times SET {', '.join(set_parts)} WHERE id = %s RETURNING *"
|
||||||
updated = execute_query(query, tuple(values))
|
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
|
return updated[0] if updated else None
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@ -3082,10 +3163,10 @@ async def patch_time_entry_v1(
|
|||||||
@router.get("/time/{time_id}/settlement-options", tags=["Internal"])
|
@router.get("/time/{time_id}/settlement-options", tags=["Internal"])
|
||||||
async def get_time_settlement_options_v1(time_id: int):
|
async def get_time_settlement_options_v1(time_id: int):
|
||||||
"""Show valid invoice, prepaid and agreement choices before approval."""
|
"""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:
|
if not entry:
|
||||||
raise HTTPException(status_code=404, detail="Time entry not found")
|
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"])
|
@router.post("/time/{time_id}/approve", tags=["Internal"])
|
||||||
@ -3118,7 +3199,7 @@ async def approve_time_entry_v1(
|
|||||||
raise HTTPException(status_code=400, detail="entry_type is required before approval")
|
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)
|
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"}
|
recommendation = options.get("recommended") or {"method": "invoice"}
|
||||||
billing_method = str(payload.get("billing_method") or recommendation.get("method") or "invoice").lower()
|
billing_method = str(payload.get("billing_method") or recommendation.get("method") or "invoice").lower()
|
||||||
if billing_method == "fixed_price":
|
if billing_method == "fixed_price":
|
||||||
|
|||||||
1513
fremtidige planer/eskaleringsplan-drift.md
Normal file
1513
fremtidige planer/eskaleringsplan-drift.md
Normal file
File diff suppressed because it is too large
Load Diff
120
migrations/1043_prepaid_card_usage_alerts.sql
Normal file
120
migrations/1043_prepaid_card_usage_alerts.sql
Normal 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.';
|
||||||
11
migrations/1044_time_entry_audit_event.sql
Normal file
11
migrations/1044_time_entry_audit_event.sql
Normal 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'
|
||||||
|
));
|
||||||
15
migrations/1045_vtiger_case_hub_imports.sql
Normal file
15
migrations/1045_vtiger_case_hub_imports.sql
Normal 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);
|
||||||
20
migrations/1046_hub_case_vtiger_exports.sql
Normal file
20
migrations/1046_hub_case_vtiger_exports.sql
Normal 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);
|
||||||
@ -1,6 +1,12 @@
|
|||||||
from pathlib import Path
|
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]
|
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.
|
# stored on a time entry.
|
||||||
assert "COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id" in router_source
|
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
|
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,
|
||||||
|
}]
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@ -43,6 +44,116 @@ def test_normalizes_vtiger_subscription():
|
|||||||
assert len(item["source_hash"]) == 64
|
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 & 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():
|
def test_source_frequency_is_normalized_to_hub_interval():
|
||||||
assert router._hub_interval("monthly_3_last_day") == "monthly"
|
assert router._hub_interval("monthly_3_last_day") == "monthly"
|
||||||
assert router._hub_interval("Quarterly") == "quarterly"
|
assert router._hub_interval("Quarterly") == "quarterly"
|
||||||
|
|||||||
@ -487,6 +487,59 @@ def test_case_route_access_allows_view_permission_for_get():
|
|||||||
assert result is user
|
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():
|
def test_case_route_access_requires_edit_for_nested_post():
|
||||||
user = {"permissions": ["cases.view", "cases.create"]}
|
user = {"permissions": ["cases.view", "cases.create"]}
|
||||||
|
|
||||||
|
|||||||
@ -113,6 +113,92 @@ def test_customerless_time_recommends_internal_settlement():
|
|||||||
assert result["agreements"] == []
|
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():
|
def test_case_time_form_sends_local_time_and_multiple_employees():
|
||||||
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
||||||
manual_form_script = template.split("async function createManualTimeV1(event)", 1)[1].split(
|
manual_form_script = template.split("async function createManualTimeV1(event)", 1)[1].split(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user