Compare commits
4 Commits
ee274a939a
...
06dffbc521
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06dffbc521 | ||
|
|
28a92eeb55 | ||
|
|
81b8229db1 | ||
|
|
abfb945679 |
21
MDfile/RELEASE_NOTES_v2.8.17.md
Normal file
21
MDfile/RELEASE_NOTES_v2.8.17.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# BMC Hub v2.8.17
|
||||||
|
|
||||||
|
## Sager, tid og vTiger
|
||||||
|
|
||||||
|
- Udvider sagsvisningen med sagstræ, samlet historik og relationstræ.
|
||||||
|
- Forbedrer tidskø, manuel tidsregistrering og revisionsspor for ændrede tidsregistreringer.
|
||||||
|
- Tilføjer idempotent import af individuelle vTiger-sager til Hub og eksportsporing fra Hub til vTiger.
|
||||||
|
|
||||||
|
## Drift og kundeplaner
|
||||||
|
|
||||||
|
- Tilføjer den fremtidige plan for **Eskaleringsplan** under kunden.
|
||||||
|
- Planen fastslår, at den eksisterende Drift-fane, alarmer, integrationer, mappings og sagslinks bevares ved en senere implementering.
|
||||||
|
|
||||||
|
## Øvrigt
|
||||||
|
|
||||||
|
- Tilføjer opfølgning ved 80 % og 100 % forbrug af klippekort.
|
||||||
|
- Indeholder Kuando Busylight-integration og forbedringer af sagsoprettelse og planner.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
Kør migrationerne `1043` til `1046` i rækkefølge før eller sammen med opstart af versionen.
|
||||||
@ -221,7 +221,7 @@ class UserProfileUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def _allowed_case_types() -> list[str]:
|
def _allowed_case_types() -> list[str]:
|
||||||
fallback = ["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]
|
fallback = ["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"]
|
||||||
try:
|
try:
|
||||||
rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or []
|
rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or []
|
||||||
if rows:
|
if rows:
|
||||||
@ -229,7 +229,7 @@ def _allowed_case_types() -> list[str]:
|
|||||||
configured = json.loads(rows[0].get("value") or "[]")
|
configured = json.loads(rows[0].get("value") or "[]")
|
||||||
values = [str(value).strip().lower() for value in configured if str(value).strip()]
|
values = [str(value).strip().lower() for value in configured if str(value).strip()]
|
||||||
if values:
|
if values:
|
||||||
return list(dict.fromkeys(values + (["pipeline"] if "pipeline" not in values else [])))
|
return list(dict.fromkeys(values))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Could not load configured case types for profile preference", exc_info=True)
|
logger.warning("Could not load configured case types for profile preference", exc_info=True)
|
||||||
return fallback
|
return fallback
|
||||||
|
|||||||
@ -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>`});
|
||||||
|
|||||||
@ -229,7 +229,7 @@ def update_settings(payload: PlannerSettingsInput, current_user: dict = Depends(
|
|||||||
def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str] = Query(default=[]),
|
def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str] = Query(default=[]),
|
||||||
case_type: list[str] = Query(default=[]), responsible_id: int | None = None,
|
case_type: list[str] = Query(default=[]), responsible_id: int | None = None,
|
||||||
department_id: int | None = None,
|
department_id: int | None = None,
|
||||||
planning: Literal["all", "unplanned", "partial", "planned"] = "all", mine: bool = False,
|
planning: Literal["all", "unplanned", "partial", "planned", "overdue"] = "all", mine: bool = False,
|
||||||
urgent: bool = False, sort: Literal["priority", "remaining", "updated"] = "priority",
|
urgent: bool = False, sort: Literal["priority", "remaining", "updated"] = "priority",
|
||||||
limit: int = Query(100, ge=1, le=300), current_user: dict = Depends(get_current_user)):
|
limit: int = Query(100, ge=1, le=300), current_user: dict = Depends(get_current_user)):
|
||||||
_require_view(current_user)
|
_require_view(current_user)
|
||||||
@ -239,7 +239,9 @@ def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str
|
|||||||
COALESCE(s.estimated_minutes, 0) AS estimated_minutes,
|
COALESCE(s.estimated_minutes, 0) AS estimated_minutes,
|
||||||
COALESCE(used.used_minutes, 0) AS used_minutes,
|
COALESCE(used.used_minutes, 0) AS used_minutes,
|
||||||
used.latest_registration_at,
|
used.latest_registration_at,
|
||||||
COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)::int AS planned_minutes
|
COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)::int AS planned_minutes,
|
||||||
|
BOOL_OR(a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE) AS overdue_planned,
|
||||||
|
MAX(a.ends_at) FILTER (WHERE a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE) AS overdue_planned_until
|
||||||
FROM sag_sager s JOIN customers c ON c.id=s.customer_id
|
FROM sag_sager s JOIN customers c ON c.id=s.customer_id
|
||||||
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id LEFT JOIN planner_allocations a ON a.sag_id=s.id
|
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id LEFT JOIN planner_allocations a ON a.sag_id=s.id
|
||||||
LEFT JOIN groups department ON department.id=s.assigned_group_id
|
LEFT JOIN groups department ON department.id=s.assigned_group_id
|
||||||
@ -266,6 +268,7 @@ def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str
|
|||||||
if planning == "unplanned": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)=0"
|
if planning == "unplanned": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)=0"
|
||||||
elif planning == "partial": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)<COALESCE(s.estimated_minutes,0)"
|
elif planning == "partial": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)<COALESCE(s.estimated_minutes,0)"
|
||||||
elif planning == "planned": sql += " HAVING COALESCE(s.estimated_minutes,0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>=s.estimated_minutes"
|
elif planning == "planned": sql += " HAVING COALESCE(s.estimated_minutes,0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>=s.estimated_minutes"
|
||||||
|
elif planning == "overdue": sql += " HAVING BOOL_OR(a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE)"
|
||||||
order = {"priority": "CASE s.priority::text WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, s.updated_at DESC",
|
order = {"priority": "CASE s.priority::text WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, s.updated_at DESC",
|
||||||
"remaining": "(COALESCE(s.estimated_minutes,0)-COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)) DESC",
|
"remaining": "(COALESCE(s.estimated_minutes,0)-COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)) DESC",
|
||||||
"updated": "s.updated_at DESC"}[sort]
|
"updated": "s.updated_at DESC"}[sort]
|
||||||
@ -290,6 +293,65 @@ def case_time_entries(sag_id: int, current_user: dict = Depends(get_current_user
|
|||||||
return {"sag_id": sag_id, "total_minutes": sum(int(r.get("minutes") or 0) for r in rows), "items": rows}
|
return {"sag_id": sag_id, "total_minutes": sum(int(r.get("minutes") or 0) for r in rows), "items": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cases/{sag_id}/allocations")
|
||||||
|
def case_allocations(sag_id: int, current_user: dict = Depends(get_current_user)):
|
||||||
|
"""Return the persisted plan for one case for the embedded case planner."""
|
||||||
|
_require_view(current_user)
|
||||||
|
case_row = execute_query_single(
|
||||||
|
"""SELECT s.id, s.titel, s.estimated_minutes, s.deferred_until,
|
||||||
|
s.deferred_until_case_id, s.deferred_until_status,
|
||||||
|
waiting.titel AS deferred_case_title, waiting.status AS deferred_case_current_status
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN sag_sager waiting ON waiting.id=s.deferred_until_case_id
|
||||||
|
WHERE s.id=%s AND s.deleted_at IS NULL""",
|
||||||
|
(sag_id,),
|
||||||
|
)
|
||||||
|
if not case_row:
|
||||||
|
raise HTTPException(404, "Sagen findes ikke")
|
||||||
|
rows = execute_query(
|
||||||
|
"""SELECT a.id, a.sag_id, a.user_id, a.starts_at, a.ends_at, a.note,
|
||||||
|
a.conflict_status, a.locked, a.version,
|
||||||
|
COALESCE(u.full_name,u.username,'Ukendt') AS user_name
|
||||||
|
FROM planner_allocations a
|
||||||
|
LEFT JOIN users u ON u.user_id=a.user_id
|
||||||
|
WHERE a.sag_id=%s AND a.deleted_at IS NULL
|
||||||
|
ORDER BY a.starts_at, a.id""",
|
||||||
|
(sag_id,),
|
||||||
|
) or []
|
||||||
|
planned_minutes = sum(_minutes(_aware(row["starts_at"]), _aware(row["ends_at"])) for row in rows)
|
||||||
|
subcases = execute_query(
|
||||||
|
"""SELECT child.id, child.titel, child.status, child.type,
|
||||||
|
child.ansvarlig_bruger_id, child.estimated_minutes,
|
||||||
|
child.deferred_until, child.deferred_until_case_id, child.deferred_until_status,
|
||||||
|
COALESCE(owner.full_name,owner.username) AS responsible_name,
|
||||||
|
waiting.titel AS deferred_case_title,
|
||||||
|
waiting.status AS deferred_case_current_status
|
||||||
|
FROM sag_relationer relation
|
||||||
|
JOIN sag_sager child ON child.id=relation.målsag_id AND child.deleted_at IS NULL
|
||||||
|
LEFT JOIN users owner ON owner.user_id=child.ansvarlig_bruger_id
|
||||||
|
LEFT JOIN sag_sager waiting ON waiting.id=child.deferred_until_case_id
|
||||||
|
WHERE relation.kilde_sag_id=%s AND LOWER(relation.relationstype)='undersag'
|
||||||
|
AND relation.deleted_at IS NULL
|
||||||
|
ORDER BY child.created_at, child.id""",
|
||||||
|
(sag_id,),
|
||||||
|
) or []
|
||||||
|
return {
|
||||||
|
"sag_id": sag_id,
|
||||||
|
"estimated_minutes": case_row.get("estimated_minutes"),
|
||||||
|
"planned_minutes": planned_minutes,
|
||||||
|
"remaining_minutes": max(0, int(case_row.get("estimated_minutes") or 0) - planned_minutes),
|
||||||
|
"deferred": {
|
||||||
|
"until": case_row.get("deferred_until"),
|
||||||
|
"case_id": case_row.get("deferred_until_case_id"),
|
||||||
|
"case_title": case_row.get("deferred_case_title"),
|
||||||
|
"case_status": case_row.get("deferred_case_current_status"),
|
||||||
|
"target_status": case_row.get("deferred_until_status"),
|
||||||
|
},
|
||||||
|
"subcases": subcases,
|
||||||
|
"items": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/cases/{sag_id}/estimate")
|
@router.patch("/cases/{sag_id}/estimate")
|
||||||
def update_case_estimate(sag_id: int, payload: CaseEstimateInput, current_user: dict = Depends(get_current_user)):
|
def update_case_estimate(sag_id: int, payload: CaseEstimateInput, current_user: dict = Depends(get_current_user)):
|
||||||
permissions = _permissions(current_user)
|
permissions = _permissions(current_user)
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
.untimed-registers{position:absolute;left:28px;right:4px;bottom:4px;z-index:4;display:flex;flex-wrap:wrap;gap:4px;pointer-events:none}.untimed-register{max-width:100%;border:1px solid #75e0a7;border-left:3px solid #079455;border-radius:6px;background:#ecfdf3;color:#05603a;padding:3px 7px;font-size:.68rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
.untimed-registers{position:absolute;left:28px;right:4px;bottom:4px;z-index:4;display:flex;flex-wrap:wrap;gap:4px;pointer-events:none}.untimed-register{max-width:100%;border:1px solid #75e0a7;border-left:3px solid #079455;border-radius:6px;background:#ecfdf3;color:#05603a;padding:3px 7px;font-size:.68rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
.planner-page{width:100%;max-width:2200px;padding:.75rem}.planner-head{margin-bottom:.75rem}.planner-layout{grid-template-columns:minmax(340px,400px) minmax(0,1fr);gap:.75rem;min-height:calc(100vh - 185px)}.calendar-panel{min-width:0}.panel-title{padding:.8rem}.case-list{padding:.6rem;gap:.5rem}.case-card{padding:.68rem}.case-card strong{display:block;line-height:1.3;overflow-wrap:anywhere}.case-card .customer{margin-top:.18rem}.week-grid{min-width:840px}
|
.planner-page{width:100%;max-width:2200px;padding:.75rem}.planner-head{margin-bottom:.75rem}.planner-layout{grid-template-columns:minmax(340px,400px) minmax(0,1fr);gap:.75rem;min-height:calc(100vh - 185px)}.calendar-panel{min-width:0}.panel-title{padding:.8rem}.case-list{padding:.6rem;gap:.5rem}.case-card{padding:.68rem}.case-card strong{display:block;line-height:1.3;overflow-wrap:anywhere}.case-card .customer{margin-top:.18rem}.week-grid{min-width:840px}
|
||||||
@media(max-width:1050px){.planner-layout{grid-template-columns:1fr}.case-panel{max-height:390px}.case-panel.collapsed .panel-title>:not(.panel-heading),.case-panel.collapsed .case-list{display:none}.case-panel.collapsed{max-height:58px}.collapse-cases{display:inline-block!important}}.collapse-cases{display:none;border:0;background:transparent}
|
@media(max-width:1050px){.planner-layout{grid-template-columns:1fr}.case-panel{max-height:390px}.case-panel.collapsed .panel-title>:not(.panel-heading),.case-panel.collapsed .case-list{display:none}.case-panel.collapsed{max-height:58px}.collapse-cases{display:inline-block!important}}.collapse-cases{display:none;border:0;background:transparent}
|
||||||
|
.quick-filters button[data-quick=overdue]{border-color:#f5b4ae;color:#b42318;font-weight:700}.quick-filters button[data-quick=overdue].active{background:#fef3f2;color:#b42318;border-color:#f04438}.overdue-case-section{border:1px solid #f5b4ae;border-radius:10px;background:#fff7f6;padding:.65rem}.overdue-case-heading{display:flex;align-items:center;gap:.4rem;color:#b42318;font-size:.8rem;font-weight:800;margin-bottom:.5rem}.case-card.overdue-planned{border-color:#f5b4ae;border-left-color:#d92d20;background:#fffafa}.case-overdue-label{display:inline-flex;align-items:center;gap:.25rem;color:#b42318;font-size:.73rem;font-weight:750;margin-top:.4rem}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -15,7 +16,7 @@
|
|||||||
<div class="planner-toolbar"><div class="planner-toggle" id="scopeToggle"><button data-scope="mine" class="active">Min plan</button><button data-scope="team">Team</button></div><div class="planner-toggle" id="viewToggle"><button data-view="day">Dag</button><button data-view="week" class="active">Uge</button><button data-view="month">Måned</button></div></div>
|
<div class="planner-toolbar"><div class="planner-toggle" id="scopeToggle"><button data-scope="mine" class="active">Min plan</button><button data-scope="team">Team</button></div><div class="planner-toggle" id="viewToggle"><button data-view="day">Dag</button><button data-view="week" class="active">Uge</button><button data-view="month">Måned</button></div></div>
|
||||||
</header>
|
</header>
|
||||||
<section class="planner-layout">
|
<section class="planner-layout">
|
||||||
<aside class="case-panel" id="casePanel"><div class="panel-title"><div class="panel-heading d-flex justify-content-between"><strong>Sager til planlægning</strong><button class="collapse-cases" aria-label="Fold sagsliste"><i class="bi bi-chevron-up"></i></button></div><div class="case-search mt-2"><input id="caseSearch" placeholder="Sagsnr., kunde, titel eller ansvarlig…"><i class="bi bi-search position-absolute end-0 me-2 mt-2 text-muted"></i></div><div class="filter-row"><select id="statusFilter"><option value="">Alle statusser</option><option>åben</option><option>i_gang</option><option>on_hold</option></select><select id="priorityFilter"><option value="">Alle prioriteter</option><option value="urgent">Haster</option><option value="high">Høj</option><option value="normal">Normal</option><option value="low">Lav</option></select><select id="typeFilter"><option value="">Alle sagstyper</option></select><select id="responsibleFilter"><option value="">Alle ansvarlige</option></select><select id="departmentFilter"><option value="">Alle afdelinger</option></select><select id="sortFilter"><option value="priority">Prioritet</option><option value="remaining">Resterende tid</option><option value="updated">Senest ændret</option></select></div><div class="quick-filters"><button data-quick="unplanned" class="active">Ikke planlagt</button><button data-quick="partial">Delvist planlagt</button><button data-quick="mine">Mine sager</button><button data-quick="urgent">Haster</button></div></div><div class="case-list" id="caseList"><div class="empty">Henter sager…</div></div></aside>
|
<aside class="case-panel" id="casePanel"><div class="panel-title"><div class="panel-heading d-flex justify-content-between"><strong>Sager til planlægning</strong><button class="collapse-cases" aria-label="Fold sagsliste"><i class="bi bi-chevron-up"></i></button></div><div class="case-search mt-2"><input id="caseSearch" placeholder="Sagsnr., kunde, titel eller ansvarlig…"><i class="bi bi-search position-absolute end-0 me-2 mt-2 text-muted"></i></div><div class="filter-row"><select id="statusFilter"><option value="">Alle statusser</option><option>åben</option><option>i_gang</option><option>on_hold</option></select><select id="priorityFilter"><option value="">Alle prioriteter</option><option value="urgent">Haster</option><option value="high">Høj</option><option value="normal">Normal</option><option value="low">Lav</option></select><select id="typeFilter"><option value="">Alle sagstyper</option></select><select id="responsibleFilter"><option value="">Alle ansvarlige</option></select><select id="departmentFilter"><option value="">Alle afdelinger</option></select><select id="sortFilter"><option value="priority">Prioritet</option><option value="remaining">Resterende tid</option><option value="updated">Senest ændret</option></select></div><div class="quick-filters"><button data-quick="overdue">Overskredet plan</button><button data-quick="unplanned" class="active">Ikke planlagt</button><button data-quick="partial">Delvist planlagt</button><button data-quick="mine">Mine sager</button><button data-quick="urgent">Haster</button></div></div><div class="case-list" id="caseList"><div class="empty">Henter sager…</div></div></aside>
|
||||||
<section class="calendar-panel"><div class="calendar-top"><div class="planner-toolbar"><button id="prevPeriod" aria-label="Forrige"><i class="bi bi-chevron-left"></i></button><button id="todayBtn">I dag</button><button id="nextPeriod" aria-label="Næste"><i class="bi bi-chevron-right"></i></button><span class="period-label" id="periodLabel"></span></div><div class="capacity-legend"><span><i class="dot green"></i>Ledig</span><span><i class="dot yellow"></i>Næsten fuld</span><span><i class="dot red"></i>Fuld/overbooket</span><span><i class="dot gray"></i>Fri/fravær</span></div></div><div class="timeline" id="timeline"></div></section>
|
<section class="calendar-panel"><div class="calendar-top"><div class="planner-toolbar"><button id="prevPeriod" aria-label="Forrige"><i class="bi bi-chevron-left"></i></button><button id="todayBtn">I dag</button><button id="nextPeriod" aria-label="Næste"><i class="bi bi-chevron-right"></i></button><span class="period-label" id="periodLabel"></span></div><div class="capacity-legend"><span><i class="dot green"></i>Ledig</span><span><i class="dot yellow"></i>Næsten fuld</span><span><i class="dot red"></i>Fuld/overbooket</span><span><i class="dot gray"></i>Fri/fravær</span></div></div><div class="timeline" id="timeline"></div></section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@ -23,4 +24,4 @@
|
|||||||
<dialog class="allocation-dialog" id="timeDialog"><form method="dialog"><div class="dialog-body"><small class="text-muted">Faktisk registreret tid</small><h5 id="timeDialogTitle"></h5><div id="timeDialogTotal" class="mb-3 fw-semibold"></div><div id="timeEntryList" class="d-flex flex-column gap-2"></div></div><div class="dialog-actions"><button value="cancel" class="btn btn-light">Luk</button><a id="timeDialogCaseLink" class="btn btn-primary">Åbn sag</a></div></form></dialog>
|
<dialog class="allocation-dialog" id="timeDialog"><form method="dialog"><div class="dialog-body"><small class="text-muted">Faktisk registreret tid</small><h5 id="timeDialogTitle"></h5><div id="timeDialogTotal" class="mb-3 fw-semibold"></div><div id="timeEntryList" class="d-flex flex-column gap-2"></div></div><div class="dialog-actions"><button value="cancel" class="btn btn-light">Luk</button><a id="timeDialogCaseLink" class="btn btn-primary">Åbn sag</a></div></form></dialog>
|
||||||
<div class="toast-stack" id="toastStack"></div>
|
<div class="toast-stack" id="toastStack"></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_js %}<script src="/static/js/planner.js?v=5"></script>{% endblock %}
|
{% block extra_js %}<script src="/static/js/planner.js?v=7"></script>{% endblock %}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ from uuid import uuid4
|
|||||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends
|
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection
|
from app.core.database import execute_query, execute_query_single, execute_update, table_has_column, get_db_connection, release_db_connection
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
|
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@ -108,74 +108,33 @@ def _ensure_case_internet_connection_tag(sag_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_case_status(status_value: Optional[str]) -> str:
|
def _normalize_case_status(status_value: Optional[str]) -> str:
|
||||||
allowed_statuses = []
|
canonical = {
|
||||||
seen = set()
|
"ny": "Ny",
|
||||||
|
"tildelt": "Tildelt",
|
||||||
def _add_status(value: Optional[str]) -> None:
|
"igangværende": "Igangværende",
|
||||||
candidate = str(value or "").strip()
|
"afventer": "Afventer",
|
||||||
if not candidate:
|
"løst": "Løst",
|
||||||
return
|
"kunde svaret": "Kunde svaret",
|
||||||
key = candidate.lower()
|
"lukket": "Lukket",
|
||||||
if key in seen:
|
}
|
||||||
return
|
legacy_aliases = {
|
||||||
seen.add(key)
|
"åben": "Ny",
|
||||||
allowed_statuses.append(candidate)
|
"open": "Ny",
|
||||||
|
"under behandling": "Igangværende",
|
||||||
try:
|
"i_gang": "Igangværende",
|
||||||
setting_row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("case_statuses",))
|
"in progress": "Igangværende",
|
||||||
if setting_row and setting_row.get("value"):
|
"on_hold": "Afventer",
|
||||||
parsed = json.loads(setting_row.get("value") or "[]")
|
"resolved": "Løst",
|
||||||
for item in parsed if isinstance(parsed, list) else []:
|
"closed": "Lukket",
|
||||||
if isinstance(item, str):
|
"afsluttet": "Lukket",
|
||||||
value = item.strip()
|
"done": "Lukket",
|
||||||
elif isinstance(item, dict):
|
}
|
||||||
value = str(item.get("value") or "").strip()
|
normalized = str(status_value or "Ny").strip().lower()
|
||||||
else:
|
if normalized in canonical:
|
||||||
value = ""
|
return canonical[normalized]
|
||||||
_add_status(value)
|
if normalized in legacy_aliases:
|
||||||
except Exception:
|
return legacy_aliases[normalized]
|
||||||
pass
|
raise HTTPException(status_code=400, detail="Ugyldig status. Vælg en af de faste sagsstatuser.")
|
||||||
|
|
||||||
# Include historical/current DB statuses so legacy values remain valid
|
|
||||||
try:
|
|
||||||
rows = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
|
|
||||||
for row in rows:
|
|
||||||
_add_status(row.get("status"))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not allowed_statuses:
|
|
||||||
allowed_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
|
|
||||||
|
|
||||||
allowed_map = {s.lower(): s for s in allowed_statuses}
|
|
||||||
open_aliases = {"åben", "open", "under behandling", "afventer", "i_gang", "on_hold"}
|
|
||||||
closed_aliases = {"lukket", "closed", "løst", "afsluttet", "resolved", "done"}
|
|
||||||
open_default = allowed_map.get("åben", allowed_statuses[0])
|
|
||||||
closed_default = allowed_map.get("lukket", allowed_map.get("løst", open_default))
|
|
||||||
|
|
||||||
if not status_value:
|
|
||||||
return open_default
|
|
||||||
|
|
||||||
normalized = str(status_value).strip().lower()
|
|
||||||
if normalized in allowed_map:
|
|
||||||
return allowed_map[normalized]
|
|
||||||
|
|
||||||
if normalized in open_aliases:
|
|
||||||
return open_default
|
|
||||||
|
|
||||||
if normalized in closed_aliases:
|
|
||||||
return closed_default
|
|
||||||
|
|
||||||
# Backward compatibility for legacy mapping
|
|
||||||
if normalized == "afventer" and "åben" in allowed_map:
|
|
||||||
return open_default
|
|
||||||
|
|
||||||
# Do not force unknown values back to default; preserve user-entered/custom DB values
|
|
||||||
raw_value = str(status_value).strip()
|
|
||||||
if raw_value:
|
|
||||||
return raw_value
|
|
||||||
|
|
||||||
return open_default
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
|
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
|
||||||
@ -442,6 +401,12 @@ class SagListPreferencesUpdate(BaseModel):
|
|||||||
hidden_columns: Optional[List[str]] = None
|
hidden_columns: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CaseAssignmentsUpdate(BaseModel):
|
||||||
|
group_id: Optional[int] = None
|
||||||
|
user_ids: List[int] = Field(default_factory=list)
|
||||||
|
primary_user_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
SAG_LIST_COLUMN_KEYS = (
|
SAG_LIST_COLUMN_KEYS = (
|
||||||
"id", "company", "contact", "description", "type", "priority", "status",
|
"id", "company", "contact", "description", "type", "priority", "status",
|
||||||
"owner", "group", "next_todo", "created", "start", "deferred", "deadline",
|
"owner", "group", "next_todo", "created", "start", "deferred", "deadline",
|
||||||
@ -1022,23 +987,49 @@ async def create_sag(request: Request, data: dict):
|
|||||||
try:
|
try:
|
||||||
if not data.get("titel"):
|
if not data.get("titel"):
|
||||||
raise HTTPException(status_code=400, detail="titel is required")
|
raise HTTPException(status_code=400, detail="titel is required")
|
||||||
if not data.get("customer_id"):
|
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
|
||||||
|
customer_required_types = {"ticket", "salg", "bogholderi", "projekt", "bug_report", "service", "pipeline", "ordre", "opgave", "abonnement"}
|
||||||
|
if case_type == "intern_ticket":
|
||||||
|
# Internal work always belongs to BMC Networks and is owned by its creator.
|
||||||
|
data["customer_id"] = 8
|
||||||
|
data["customer_id"] = _coerce_optional_int(data.get("customer_id"), "customer_id")
|
||||||
|
if case_type in customer_required_types and not data.get("customer_id"):
|
||||||
raise HTTPException(status_code=400, detail="customer_id is required")
|
raise HTTPException(status_code=400, detail="customer_id is required")
|
||||||
|
_validate_customer_id(data.get("customer_id"))
|
||||||
|
|
||||||
status = _normalize_case_status(data.get("status"))
|
status = _normalize_case_status(data.get("status"))
|
||||||
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
|
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
|
||||||
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
|
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
|
||||||
current_user_id = _get_user_id_from_request(request)
|
current_user_id = _get_user_id_from_request(request)
|
||||||
raw_responsible = data.get("ansvarlig_bruger_id") if "ansvarlig_bruger_id" in data else current_user_id
|
raw_responsible = data.get("ansvarlig_bruger_id") if "ansvarlig_bruger_id" in data else current_user_id
|
||||||
|
if case_type == "intern_ticket":
|
||||||
|
raw_responsible = current_user_id
|
||||||
ansvarlig_bruger_id = _coerce_optional_int(raw_responsible, "ansvarlig_bruger_id")
|
ansvarlig_bruger_id = _coerce_optional_int(raw_responsible, "ansvarlig_bruger_id")
|
||||||
assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id")
|
assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id")
|
||||||
|
|
||||||
_validate_user_id(ansvarlig_bruger_id)
|
_validate_user_id(ansvarlig_bruger_id)
|
||||||
_validate_group_id(assigned_group_id)
|
_validate_group_id(assigned_group_id)
|
||||||
|
|
||||||
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
|
|
||||||
pipeline = data.get("pipeline")
|
pipeline = data.get("pipeline")
|
||||||
order_items = data.get("order_items", [])
|
order_items = data.get("order_items", [])
|
||||||
|
type_details = data.get("type_details") or {}
|
||||||
|
if not isinstance(type_details, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="type_details skal være et objekt")
|
||||||
|
if len(type_details) > 20:
|
||||||
|
raise HTTPException(status_code=400, detail="For mange typefelter")
|
||||||
|
normalized_type_details = {}
|
||||||
|
for key, value in type_details.items():
|
||||||
|
if not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", str(key)):
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldigt typefeltnavn")
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(value, (str, int, float, bool)):
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldig værdi i typefelt")
|
||||||
|
normalized = str(value).strip()
|
||||||
|
if len(normalized) > 10000:
|
||||||
|
raise HTTPException(status_code=400, detail="Et typefelt er for langt")
|
||||||
|
if normalized:
|
||||||
|
normalized_type_details[str(key)] = normalized
|
||||||
raw_contact_ids = data.get("contact_ids") or []
|
raw_contact_ids = data.get("contact_ids") or []
|
||||||
if not isinstance(raw_contact_ids, list):
|
if not isinstance(raw_contact_ids, list):
|
||||||
raise HTTPException(status_code=400, detail="contact_ids skal være en liste")
|
raise HTTPException(status_code=400, detail="contact_ids skal være en liste")
|
||||||
@ -1132,12 +1123,12 @@ async def create_sag(request: Request, data: dict):
|
|||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sag_sager
|
INSERT INTO sag_sager
|
||||||
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status,
|
(titel, beskrivelse, template_key, type, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status,
|
||||||
pipeline_amount, pipeline_probability, pipeline_stage_id, pipeline_description)
|
pipeline_amount, pipeline_probability, pipeline_stage_id, pipeline_description)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
|
(data.get("titel"), data.get("beskrivelse", ""), case_type, case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
|
||||||
assigned_group_id, current_user_id, deadline, deferred_until, data.get("deferred_until_case_id"),
|
assigned_group_id, current_user_id, deadline, deferred_until, data.get("deferred_until_case_id"),
|
||||||
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
|
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
|
||||||
)
|
)
|
||||||
@ -1145,6 +1136,34 @@ async def create_sag(request: Request, data: dict):
|
|||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=500, detail="Failed to create case")
|
raise HTTPException(status_code=500, detail="Failed to create case")
|
||||||
|
|
||||||
|
if normalized_type_details:
|
||||||
|
cursor.execute(
|
||||||
|
"""INSERT INTO sag_case_details (sag_id, details, created_by_user_id, updated_by_user_id)
|
||||||
|
VALUES (%s, %s::jsonb, %s, %s)""",
|
||||||
|
(result["id"], json.dumps(normalized_type_details), current_user_id, current_user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
anydesk_id = normalized_type_details.get("anydesk_id")
|
||||||
|
if case_type == "ticket" and anydesk_id:
|
||||||
|
if len(anydesk_id) > 64 or not re.fullmatch(r"[0-9][0-9 .-]*", anydesk_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldigt AnyDesk-ID")
|
||||||
|
cursor.execute(
|
||||||
|
"""INSERT INTO sag_anydesk_ids (sag_id, anydesk_id, is_primary, created_by_user_id)
|
||||||
|
VALUES (%s, %s, TRUE, %s)
|
||||||
|
ON CONFLICT (sag_id, anydesk_id) DO UPDATE SET deleted_at=NULL, is_primary=TRUE, updated_at=NOW()""",
|
||||||
|
(result["id"], anydesk_id, current_user_id),
|
||||||
|
)
|
||||||
|
cursor.execute("UPDATE sag_anydesk_ids SET is_primary=FALSE WHERE sag_id=%s AND anydesk_id<>%s AND deleted_at IS NULL", (result["id"], anydesk_id))
|
||||||
|
|
||||||
|
solution = normalized_type_details.get("solution")
|
||||||
|
if case_type == "ticket" and solution:
|
||||||
|
cursor.execute(
|
||||||
|
"""INSERT INTO sag_solutions (sag_id, title, description, solution_type, result, created_by_user_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (sag_id) DO NOTHING""",
|
||||||
|
(result["id"], data.get("titel"), solution, "case_solution", "draft", current_user_id),
|
||||||
|
)
|
||||||
|
|
||||||
if contact_ids:
|
if contact_ids:
|
||||||
cursor.execute("SELECT id FROM contacts WHERE id = ANY(%s)", (contact_ids,))
|
cursor.execute("SELECT id FROM contacts WHERE id = ANY(%s)", (contact_ids,))
|
||||||
existing_contact_ids = {int(row["id"]) for row in cursor.fetchall()}
|
existing_contact_ids = {int(row["id"]) for row in cursor.fetchall()}
|
||||||
@ -1514,11 +1533,22 @@ async def update_my_sag_list_preferences(request: Request, payload: SagListPrefe
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/sag/{sag_id}/modules")
|
@router.get("/sag/{sag_id}/modules")
|
||||||
async def get_case_module_prefs(sag_id: int):
|
async def get_case_module_prefs(sag_id: int, current_user: dict = Depends(get_current_user)):
|
||||||
"""Get module visibility preferences for a case."""
|
"""Get module visibility preferences for the current user on a case."""
|
||||||
try:
|
try:
|
||||||
query = "SELECT module_key, is_enabled FROM sag_module_prefs WHERE sag_id = %s"
|
user_id = int(current_user["id"])
|
||||||
prefs = execute_query(query, (sag_id,))
|
query = """
|
||||||
|
SELECT module_key, is_enabled
|
||||||
|
FROM sag_module_prefs
|
||||||
|
WHERE sag_id = %s
|
||||||
|
AND module_key <> 'buzzwords'
|
||||||
|
UNION ALL
|
||||||
|
SELECT module_key, is_enabled
|
||||||
|
FROM user_sag_module_prefs
|
||||||
|
WHERE sag_id = %s
|
||||||
|
AND user_id = %s
|
||||||
|
"""
|
||||||
|
prefs = execute_query(query, (sag_id, sag_id, user_id))
|
||||||
return prefs or []
|
return prefs or []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ Error getting module prefs: %s", e)
|
logger.error("❌ Error getting module prefs: %s", e)
|
||||||
@ -1526,8 +1556,12 @@ async def get_case_module_prefs(sag_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/sag/{sag_id}/modules")
|
@router.post("/sag/{sag_id}/modules")
|
||||||
async def set_case_module_pref(sag_id: int, data: dict):
|
async def set_case_module_pref(
|
||||||
"""Set module visibility preference for a case."""
|
sag_id: int,
|
||||||
|
data: dict,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Set a module visibility preference, scoped per user for Buzzwords."""
|
||||||
try:
|
try:
|
||||||
module_key = data.get("module_key")
|
module_key = data.get("module_key")
|
||||||
is_enabled = data.get("is_enabled")
|
is_enabled = data.get("is_enabled")
|
||||||
@ -1535,6 +1569,18 @@ async def set_case_module_pref(sag_id: int, data: dict):
|
|||||||
if not module_key or is_enabled is None:
|
if not module_key or is_enabled is None:
|
||||||
raise HTTPException(status_code=400, detail="module_key and is_enabled are required")
|
raise HTTPException(status_code=400, detail="module_key and is_enabled are required")
|
||||||
|
|
||||||
|
if module_key == "buzzwords":
|
||||||
|
if not _table_exists("user_sag_module_prefs"):
|
||||||
|
raise HTTPException(status_code=409, detail="Buzzwords-indstillingen er ikke klar. Kør migration 1042 først.")
|
||||||
|
query = """
|
||||||
|
INSERT INTO user_sag_module_prefs (user_id, sag_id, module_key, is_enabled)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
ON CONFLICT (user_id, sag_id, module_key)
|
||||||
|
DO UPDATE SET is_enabled = EXCLUDED.is_enabled
|
||||||
|
RETURNING module_key, is_enabled
|
||||||
|
"""
|
||||||
|
result = execute_query(query, (int(current_user["id"]), sag_id, module_key, bool(is_enabled)))
|
||||||
|
else:
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO sag_module_prefs (sag_id, module_key, is_enabled)
|
INSERT INTO sag_module_prefs (sag_id, module_key, is_enabled)
|
||||||
VALUES (%s, %s, %s)
|
VALUES (%s, %s, %s)
|
||||||
@ -1555,6 +1601,83 @@ async def set_case_module_pref(sag_id: int, data: dict):
|
|||||||
# SAG TODO STEPS
|
# SAG TODO STEPS
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
|
@router.get("/sag/{sag_id}/assignments", dependencies=[Depends(case_edit_access)])
|
||||||
|
async def get_case_assignments(sag_id: int, group_id: Optional[int] = Query(None)):
|
||||||
|
"""Return selected employees and, when a group is chosen, its available members."""
|
||||||
|
case_row = execute_query_single(
|
||||||
|
"SELECT id, assigned_group_id, ansvarlig_bruger_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL",
|
||||||
|
(sag_id,),
|
||||||
|
)
|
||||||
|
if not case_row:
|
||||||
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
effective_group_id = group_id if group_id is not None else case_row.get("assigned_group_id")
|
||||||
|
selected = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT u.user_id, COALESCE(NULLIF(TRIM(u.full_name), ''), u.username) AS display_name
|
||||||
|
FROM sag_case_assignees a JOIN users u ON u.user_id=a.user_id
|
||||||
|
WHERE a.sag_id=%s ORDER BY display_name
|
||||||
|
""",
|
||||||
|
(sag_id,),
|
||||||
|
) or []
|
||||||
|
members = []
|
||||||
|
if effective_group_id:
|
||||||
|
_validate_group_id(effective_group_id)
|
||||||
|
members = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT u.user_id, COALESCE(NULLIF(TRIM(u.full_name), ''), u.username) AS display_name
|
||||||
|
FROM user_groups ug JOIN users u ON u.user_id=ug.user_id
|
||||||
|
WHERE ug.group_id=%s AND u.is_active=true ORDER BY display_name
|
||||||
|
""",
|
||||||
|
(effective_group_id,),
|
||||||
|
) or []
|
||||||
|
return {
|
||||||
|
"group_id": effective_group_id,
|
||||||
|
"primary_user_id": case_row.get("ansvarlig_bruger_id"),
|
||||||
|
"selected_user_ids": [row["user_id"] for row in selected],
|
||||||
|
"members": members,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/sag/{sag_id}/assignments", dependencies=[Depends(case_edit_access)])
|
||||||
|
async def save_case_assignments(sag_id: int, payload: CaseAssignmentsUpdate, request: Request):
|
||||||
|
"""Persist every selected employee as its own assignment row."""
|
||||||
|
case_row = execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,))
|
||||||
|
if not case_row:
|
||||||
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|
||||||
|
user_ids = list(dict.fromkeys(payload.user_ids))
|
||||||
|
if payload.group_id is None:
|
||||||
|
if user_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="Vælg en gruppe før medarbejdere tildeles")
|
||||||
|
else:
|
||||||
|
_validate_group_id(payload.group_id)
|
||||||
|
group_members = execute_query(
|
||||||
|
"SELECT user_id FROM user_groups WHERE group_id=%s", (payload.group_id,)
|
||||||
|
) or []
|
||||||
|
permitted_user_ids = {row["user_id"] for row in group_members}
|
||||||
|
invalid_user_ids = [user_id for user_id in user_ids if user_id not in permitted_user_ids]
|
||||||
|
if invalid_user_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="Alle valgte medarbejdere skal være medlem af den valgte gruppe")
|
||||||
|
|
||||||
|
primary_user_id = payload.primary_user_id
|
||||||
|
if primary_user_id is not None and primary_user_id not in user_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="Primær ansvarlig skal være blandt de valgte medarbejdere")
|
||||||
|
if primary_user_id is None and user_ids:
|
||||||
|
primary_user_id = user_ids[0]
|
||||||
|
|
||||||
|
user_id = _get_user_id_from_request(request)
|
||||||
|
execute_query("DELETE FROM sag_case_assignees WHERE sag_id=%s", (sag_id,))
|
||||||
|
for assignee_id in user_ids:
|
||||||
|
execute_query(
|
||||||
|
"INSERT INTO sag_case_assignees (sag_id, user_id, assigned_by_user_id) VALUES (%s, %s, %s)",
|
||||||
|
(sag_id, assignee_id, user_id),
|
||||||
|
)
|
||||||
|
execute_query(
|
||||||
|
"UPDATE sag_sager SET assigned_group_id=%s, ansvarlig_bruger_id=%s WHERE id=%s",
|
||||||
|
(payload.group_id, primary_user_id, sag_id),
|
||||||
|
)
|
||||||
|
return await get_case_assignments(sag_id, payload.group_id)
|
||||||
|
|
||||||
@router.get("/sag/{sag_id}/todo-steps", response_model=List[TodoStep])
|
@router.get("/sag/{sag_id}/todo-steps", response_model=List[TodoStep])
|
||||||
async def list_todo_steps(sag_id: int):
|
async def list_todo_steps(sag_id: int):
|
||||||
try:
|
try:
|
||||||
@ -1725,6 +1848,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
|||||||
"""Update a case."""
|
"""Update a case."""
|
||||||
try:
|
try:
|
||||||
confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True
|
confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True
|
||||||
|
awaiting_todo = updates.pop("awaiting_todo", None)
|
||||||
# Check if case exists
|
# Check if case exists
|
||||||
check = execute_query(
|
check = execute_query(
|
||||||
"""
|
"""
|
||||||
@ -1743,13 +1867,45 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
|||||||
# Backwards compatibility: frontend sends "type", DB stores "template_key"
|
# Backwards compatibility: frontend sends "type", DB stores "template_key"
|
||||||
if "type" in updates and "template_key" not in updates:
|
if "type" in updates and "template_key" not in updates:
|
||||||
updates["template_key"] = updates.get("type")
|
updates["template_key"] = updates.get("type")
|
||||||
|
if "template_key" in updates:
|
||||||
|
updates["type"] = updates["template_key"]
|
||||||
|
if str(updates.get("template_key") or "").strip().lower() == "intern_ticket":
|
||||||
|
# Keep the same invariant when an existing case is changed to Internal ticket.
|
||||||
|
updates["customer_id"] = 8
|
||||||
|
updates["ansvarlig_bruger_id"] = _get_user_id_from_request(request)
|
||||||
|
|
||||||
if "status" in updates:
|
if "status" in updates:
|
||||||
updates["status"] = _normalize_case_status(updates.get("status"))
|
updates["status"] = _normalize_case_status(updates.get("status"))
|
||||||
closing_statuses = {"lukket", "løst", "afsluttet", "closed", "resolved", "done"}
|
closing_statuses = {"lukket"}
|
||||||
new_status = str(updates.get("status") or "").strip().lower()
|
new_status = str(updates.get("status") or "").strip().lower()
|
||||||
case_type = str(previous_row.get("template_key") or "").strip().lower()
|
case_type = str(previous_row.get("template_key") or "").strip().lower()
|
||||||
is_support_case = case_type in {"", "support", "ticket", "quickcreate", "service"}
|
is_support_case = case_type in {"", "support", "ticket", "quickcreate", "service"}
|
||||||
|
if new_status in closing_statuses and previous_status not in closing_statuses:
|
||||||
|
unfinished_time = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int AS count
|
||||||
|
FROM tmodule_times
|
||||||
|
WHERE sag_id = %s
|
||||||
|
AND (
|
||||||
|
slut_tid IS NULL
|
||||||
|
OR COALESCE(
|
||||||
|
entry_status,
|
||||||
|
CASE WHEN status IN ('approved', 'billed') THEN 'godkendt' ELSE 'afventer' END
|
||||||
|
) <> 'godkendt'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(sag_id,),
|
||||||
|
) or {}
|
||||||
|
unfinished_count = int(unfinished_time.get("count") or 0)
|
||||||
|
if unfinished_count:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"code": "case_close_blocked_unfinished_time",
|
||||||
|
"message": f"Sagen kan ikke lukkes, fordi {unfinished_count} tidsregistrering(er) stadig er aktive, i kladde eller afventer godkendelse.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if new_status in closing_statuses and previous_status not in closing_statuses and is_support_case:
|
if new_status in closing_statuses and previous_status not in closing_statuses and is_support_case:
|
||||||
has_registered_time = execute_query_single(
|
has_registered_time = execute_query_single(
|
||||||
"SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s) AS has_time",
|
"SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s) AS has_time",
|
||||||
@ -1763,6 +1919,26 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
|||||||
"message": "Der er ikke registreret tid på supportsagen. Bekræft at den skal lukkes uden tid.",
|
"message": "Der er ikke registreret tid på supportsagen. Bekræft at den skal lukkes uden tid.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
is_awaiting_transition = new_status.startswith("afventer") and not previous_status.startswith("afventer")
|
||||||
|
if is_awaiting_transition:
|
||||||
|
if not isinstance(awaiting_todo, dict):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="En TODO med hvad der afventes er påkrævet, før sagen kan sættes til Afventer",
|
||||||
|
)
|
||||||
|
todo_title = str(awaiting_todo.get("title") or "").strip()
|
||||||
|
if len(todo_title) < 3:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="TODO-titlen skal beskrive, hvad der afventes",
|
||||||
|
)
|
||||||
|
awaiting_todo = {
|
||||||
|
"title": todo_title,
|
||||||
|
"description": str(awaiting_todo.get("description") or "").strip() or None,
|
||||||
|
"due_date": awaiting_todo.get("due_date") or None,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
is_awaiting_transition = False
|
||||||
if "deadline" in updates:
|
if "deadline" in updates:
|
||||||
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
|
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
|
||||||
if "start_date" in updates:
|
if "start_date" in updates:
|
||||||
@ -1805,6 +1981,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
|||||||
"titel",
|
"titel",
|
||||||
"beskrivelse",
|
"beskrivelse",
|
||||||
"template_key",
|
"template_key",
|
||||||
|
"type",
|
||||||
"status",
|
"status",
|
||||||
"customer_id",
|
"customer_id",
|
||||||
"ansvarlig_bruger_id",
|
"ansvarlig_bruger_id",
|
||||||
@ -1833,6 +2010,28 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
|||||||
set_sql = ", ".join(set_clauses)
|
set_sql = ", ".join(set_clauses)
|
||||||
query = f"UPDATE sag_sager SET {set_sql} WHERE id = %s RETURNING *"
|
query = f"UPDATE sag_sager SET {set_sql} WHERE id = %s RETURNING *"
|
||||||
|
|
||||||
|
# Create the follow-up before changing status: an awaiting case must never
|
||||||
|
# be persisted without its required next action.
|
||||||
|
if is_awaiting_transition and awaiting_todo:
|
||||||
|
try:
|
||||||
|
todo_user_id = _get_user_id_from_request(request)
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_todo_steps (sag_id, title, description, due_date, created_by_user_id, is_next)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, true)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
sag_id,
|
||||||
|
awaiting_todo["title"],
|
||||||
|
awaiting_todo["description"],
|
||||||
|
awaiting_todo["due_date"],
|
||||||
|
todo_user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as todo_error:
|
||||||
|
logger.error("Could not create mandatory awaiting TODO for case %s: %s", sag_id, todo_error)
|
||||||
|
raise HTTPException(status_code=500, detail="TODO kunne ikke oprettes; status er ikke ændret")
|
||||||
|
|
||||||
result = execute_query(query, tuple(params))
|
result = execute_query(query, tuple(params))
|
||||||
if result:
|
if result:
|
||||||
updated_row = result[0] or {}
|
updated_row = result[0] or {}
|
||||||
@ -1869,6 +2068,12 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
|||||||
|
|
||||||
type_labels = {
|
type_labels = {
|
||||||
"ticket": "Ticket",
|
"ticket": "Ticket",
|
||||||
|
"salg": "Salg",
|
||||||
|
"bogholderi": "Bogholderi",
|
||||||
|
"admin": "Admin",
|
||||||
|
"postit": "Post-it",
|
||||||
|
"intern_ticket": "Intern ticket",
|
||||||
|
"bug_report": "Bug_report",
|
||||||
"pipeline": "Pipeline",
|
"pipeline": "Pipeline",
|
||||||
"opgave": "Opgave",
|
"opgave": "Opgave",
|
||||||
"ordre": "Ordre",
|
"ordre": "Ordre",
|
||||||
@ -1973,6 +2178,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
|||||||
|
|
||||||
class BeskrivelsePatch(BaseModel):
|
class BeskrivelsePatch(BaseModel):
|
||||||
beskrivelse: str
|
beskrivelse: str
|
||||||
|
append: bool = False
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/sag/{sag_id}/beskrivelse", dependencies=[Depends(case_edit_access)])
|
@router.patch("/sag/{sag_id}/beskrivelse", dependencies=[Depends(case_edit_access)])
|
||||||
@ -1987,8 +2193,6 @@ async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: R
|
|||||||
raise HTTPException(status_code=404, detail="Case not found")
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|
||||||
old_beskrivelse = row.get("beskrivelse")
|
old_beskrivelse = row.get("beskrivelse")
|
||||||
new_beskrivelse = body.beskrivelse
|
|
||||||
|
|
||||||
# Resolve acting user (may be None for anonymous)
|
# Resolve acting user (may be None for anonymous)
|
||||||
user_id = _get_user_id_from_request(request)
|
user_id = _get_user_id_from_request(request)
|
||||||
changed_by_name = None
|
changed_by_name = None
|
||||||
@ -2000,6 +2204,18 @@ async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: R
|
|||||||
if u:
|
if u:
|
||||||
changed_by_name = u["name"]
|
changed_by_name = u["name"]
|
||||||
|
|
||||||
|
if body.append:
|
||||||
|
existing_beskrivelse = str(old_beskrivelse or "").rstrip()
|
||||||
|
addition = body.beskrivelse.strip()
|
||||||
|
name_parts = re.findall(r"[A-Za-zÆØÅæøåÀ-ÖØ-öø-ÿ0-9]+", str(changed_by_name or "Bruger"))
|
||||||
|
initials = "".join(part[0].upper() for part in name_parts[:2]) or "BR"
|
||||||
|
append_date = datetime.now().strftime("%d/%m")
|
||||||
|
safe_addition = html.escape(addition).replace("\n", "<br>")
|
||||||
|
append_fragment = f"<hr><h6>{initials}/{append_date}</h6><p>{safe_addition}</p>"
|
||||||
|
new_beskrivelse = f"{existing_beskrivelse}\n{append_fragment}" if existing_beskrivelse else append_fragment
|
||||||
|
else:
|
||||||
|
new_beskrivelse = body.beskrivelse
|
||||||
|
|
||||||
# Write history entry
|
# Write history entry
|
||||||
execute_query(
|
execute_query(
|
||||||
"""INSERT INTO sag_beskrivelse_history
|
"""INSERT INTO sag_beskrivelse_history
|
||||||
@ -2458,12 +2674,12 @@ async def list_case_customers(sag_id: int):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to list case customers")
|
raise HTTPException(status_code=500, detail="Failed to list case customers")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sag/{sag_id}/customer/replace")
|
@router.post("/sag/{sag_id}/customer/replace", dependencies=[Depends(case_edit_access)])
|
||||||
async def replace_case_customer(sag_id: int, payload: dict):
|
async def replace_case_customer(sag_id: int, payload: dict, request: Request):
|
||||||
"""Replace the primary customer on a case and keep relations synchronized."""
|
"""Replace the primary customer on a case and keep relations synchronized."""
|
||||||
try:
|
try:
|
||||||
existing_case = execute_query_single(
|
existing_case = execute_query_single(
|
||||||
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
"SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||||
(sag_id,),
|
(sag_id,),
|
||||||
)
|
)
|
||||||
if not existing_case:
|
if not existing_case:
|
||||||
@ -2475,6 +2691,10 @@ async def replace_case_customer(sag_id: int, payload: dict):
|
|||||||
|
|
||||||
_validate_customer_id(customer_id)
|
_validate_customer_id(customer_id)
|
||||||
|
|
||||||
|
old_customer_id = existing_case.get("customer_id")
|
||||||
|
old_customer = execute_query_single("SELECT name FROM customers WHERE id = %s", (old_customer_id,)) if old_customer_id else None
|
||||||
|
new_customer = execute_query_single("SELECT name FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
|
||||||
if table_has_column("sag_sager", "customer_id"):
|
if table_has_column("sag_sager", "customer_id"):
|
||||||
execute_query(
|
execute_query(
|
||||||
"UPDATE sag_sager SET customer_id = %s WHERE id = %s",
|
"UPDATE sag_sager SET customer_id = %s WHERE id = %s",
|
||||||
@ -2517,6 +2737,32 @@ async def replace_case_customer(sag_id: int, payload: dict):
|
|||||||
(sag_id, customer_id, "Kunde"),
|
(sag_id, customer_id, "Kunde"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if old_customer_id != customer_id:
|
||||||
|
try:
|
||||||
|
changed_by = "System"
|
||||||
|
try:
|
||||||
|
user_id = _get_user_id_from_request(request)
|
||||||
|
user = execute_query_single(
|
||||||
|
"SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), ''), CONCAT('Bruger #', user_id::text)) AS name FROM users WHERE user_id = %s",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
changed_by = str((user or {}).get("name") or changed_by)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
||||||
|
VALUES (%s, %s, %s, true)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
sag_id,
|
||||||
|
changed_by,
|
||||||
|
f"🏢 Firma ændret: {(old_customer or {}).get('name') or 'Ingen kunde'} → {(new_customer or {}).get('name') or f'Kunde #{customer_id}'}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not write customer change history for case %s: %s", sag_id, exc)
|
||||||
|
|
||||||
logger.info("✅ Primary customer replaced for case %s -> customer %s", sag_id, customer_id)
|
logger.info("✅ Primary customer replaced for case %s -> customer %s", sag_id, customer_id)
|
||||||
return {"status": "ok", "sag_id": sag_id, "customer_id": customer_id}
|
return {"status": "ok", "sag_id": sag_id, "customer_id": customer_id}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@ -3826,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
|
||||||
@ -3866,6 +4240,7 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
|||||||
"""
|
"""
|
||||||
case_rows = execute_query(case_tree_query, (sag_id,)) or []
|
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,)
|
||||||
@ -4085,6 +4460,7 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
|||||||
COALESCE(NULLIF(TRIM(recorder.full_name), ''), NULLIF(TRIM(recorder.username), ''),
|
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
|
||||||
@ -4194,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
|
||||||
@ -5472,78 +5900,98 @@ def _decode_header_str(header_val):
|
|||||||
except:
|
except:
|
||||||
return str(header_val)
|
return str(header_val)
|
||||||
|
|
||||||
|
def _copy_uploaded_email_attachments_to_case(sag_id: int, email_id: int, attachments: List[Dict]) -> int:
|
||||||
|
"""Copy valid manual-email attachments into the normal case-files module."""
|
||||||
|
if not attachments or not _table_exists("sag_files"):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
has_source_email = table_has_column("sag_files", "source_email_id")
|
||||||
|
has_source_type = table_has_column("sag_files", "source_type")
|
||||||
|
copied = 0
|
||||||
|
|
||||||
|
for attachment in attachments:
|
||||||
|
filename = Path(str(attachment.get("filename") or "attachment.bin")).name
|
||||||
|
content = attachment.get("content")
|
||||||
|
if not filename or not content:
|
||||||
|
continue
|
||||||
|
content = bytes(content)
|
||||||
|
extension = Path(filename).suffix.lower().lstrip(".")
|
||||||
|
if not extension or extension not in ALLOWED_EXTENSIONS or len(content) > MAX_ATTACHMENT_SIZE:
|
||||||
|
logger.warning("Skipping unsupported or oversized email attachment %s for SAG-%s", filename, sag_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if has_source_email:
|
||||||
|
existing = execute_query(
|
||||||
|
"""SELECT 1 FROM sag_files
|
||||||
|
WHERE sag_id = %s AND source_email_id = %s AND filename = %s
|
||||||
|
LIMIT 1""",
|
||||||
|
(sag_id, email_id, filename),
|
||||||
|
) or []
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
stored_name = _generate_stored_name(filename, SAG_FILE_SUBDIR)
|
||||||
|
destination = _resolve_attachment_path(stored_name)
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
destination.write_bytes(content)
|
||||||
|
|
||||||
|
columns = ["sag_id", "filename", "content_type", "size_bytes", "stored_name"]
|
||||||
|
values: List[Any] = [
|
||||||
|
sag_id,
|
||||||
|
filename,
|
||||||
|
attachment.get("content_type") or "application/octet-stream",
|
||||||
|
len(content),
|
||||||
|
stored_name,
|
||||||
|
]
|
||||||
|
if has_source_email:
|
||||||
|
columns.append("source_email_id")
|
||||||
|
values.append(email_id)
|
||||||
|
if has_source_type:
|
||||||
|
columns.append("source_type")
|
||||||
|
values.append("email_import")
|
||||||
|
execute_query(
|
||||||
|
f"INSERT INTO sag_files ({', '.join(columns)}) VALUES ({', '.join(['%s'] * len(values))})",
|
||||||
|
tuple(values),
|
||||||
|
)
|
||||||
|
copied += 1
|
||||||
|
|
||||||
|
return copied
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sag/{sag_id}/upload-email")
|
@router.post("/sag/{sag_id}/upload-email")
|
||||||
async def upload_sag_email(sag_id: int, file: UploadFile = File(...)):
|
async def upload_sag_email(sag_id: int, file: UploadFile = File(...)):
|
||||||
"""Upload .eml/.msg, parse it, save to email_messages, and link to case."""
|
"""Import .eml/.msg into the email tab and copy its attachments to case files."""
|
||||||
content = await file.read()
|
filename = str(file.filename or "").lower()
|
||||||
filename = file.filename.lower()
|
if not (filename.endswith(".eml") or filename.endswith(".msg")):
|
||||||
email_data = {}
|
|
||||||
temp_id = uuid4().hex
|
|
||||||
|
|
||||||
# 1. Parse File
|
|
||||||
if filename.endswith('.msg'):
|
|
||||||
if not extract_msg: raise HTTPException(500, "extract-msg missing")
|
|
||||||
import io
|
|
||||||
msg = extract_msg.Message(io.BytesIO(content))
|
|
||||||
email_data = {
|
|
||||||
'message_id': msg.messageId or f"msg-{temp_id}",
|
|
||||||
'subject': msg.subject or "No Subject",
|
|
||||||
'sender_email': msg.sender or "",
|
|
||||||
'sender_name': msg.sender or "",
|
|
||||||
'recipient_email': msg.to or "",
|
|
||||||
'cc': msg.cc or "",
|
|
||||||
'body_text': msg.body,
|
|
||||||
'body_html': msg.htmlBody,
|
|
||||||
'received_date': msg.date or datetime.now(),
|
|
||||||
'folder': 'Imported',
|
|
||||||
'attachments': [],
|
|
||||||
'has_attachments': False,
|
|
||||||
'attachment_count': 0
|
|
||||||
}
|
|
||||||
elif filename.endswith('.eml'):
|
|
||||||
msg = email.message_from_bytes(content)
|
|
||||||
body_text = ""
|
|
||||||
if msg.is_multipart():
|
|
||||||
for part in msg.walk():
|
|
||||||
if part.get_content_type() == "text/plain":
|
|
||||||
body_text = part.get_payload(decode=True).decode('utf-8', errors='ignore')
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
body_text = msg.get_payload(decode=True).decode('utf-8', errors='ignore')
|
|
||||||
|
|
||||||
email_data = {
|
|
||||||
'message_id': msg.get('Message-ID', f"eml-{temp_id}"),
|
|
||||||
'in_reply_to': _decode_header_str(msg.get('In-Reply-To', '')),
|
|
||||||
'email_references': _decode_header_str(msg.get('References', '')),
|
|
||||||
'subject': _decode_header_str(msg.get('Subject', 'No Subject')),
|
|
||||||
'sender_email': _decode_header_str(msg.get('From', '')),
|
|
||||||
'sender_name': _decode_header_str(msg.get('From', '')),
|
|
||||||
'recipient_email': _decode_header_str(msg.get('To', '')),
|
|
||||||
'cc': _decode_header_str(msg.get('Cc', '')),
|
|
||||||
'received_date': datetime.now(),
|
|
||||||
'body_text': body_text,
|
|
||||||
'body_html': "",
|
|
||||||
'folder': 'Imported',
|
|
||||||
'has_attachments': False,
|
|
||||||
'attachment_count': 0,
|
|
||||||
'attachments': []
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
raise HTTPException(400, "Only .msg or .eml files allowed")
|
raise HTTPException(400, "Only .msg or .eml files allowed")
|
||||||
|
|
||||||
# 2. Save Email via Service (deduplicate)
|
content = await file.read()
|
||||||
svc = EmailService()
|
service = EmailService()
|
||||||
existing = execute_query_single("SELECT id FROM email_messages WHERE message_id = %s", (email_data['message_id'],))
|
email_data = service.parse_msg_file(content) if filename.endswith(".msg") else service.parse_eml_file(content)
|
||||||
|
if not email_data:
|
||||||
|
raise HTTPException(status_code=400, detail="E-mailfilen kunne ikke læses")
|
||||||
|
|
||||||
if existing:
|
email_id = await service.save_uploaded_email(email_data)
|
||||||
email_id = existing['id']
|
if not email_id:
|
||||||
else:
|
existing = execute_query_single(
|
||||||
# We try to strict save, keeping it simple
|
"SELECT id FROM email_messages WHERE message_id = %s",
|
||||||
email_id = await svc.save_email(email_data)
|
(email_data["message_id"],),
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=500, detail="E-mailen kunne ikke gemmes")
|
||||||
|
email_id = existing["id"]
|
||||||
|
|
||||||
# 3. Link
|
|
||||||
await add_sag_email_link(sag_id, {"email_id": email_id})
|
await add_sag_email_link(sag_id, {"email_id": email_id})
|
||||||
return {"status": "imported", "email_id": email_id}
|
attachment_files_created = _copy_uploaded_email_attachments_to_case(
|
||||||
|
sag_id,
|
||||||
|
int(email_id),
|
||||||
|
email_data.get("attachments") or [],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "imported",
|
||||||
|
"email_id": email_id,
|
||||||
|
"attachment_files_created": attachment_files_created,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sag/{sag_id}/emails/send")
|
@router.post("/sag/{sag_id}/emails/send")
|
||||||
@ -5708,8 +6156,12 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
|
|||||||
failure_detail = "Email send failed (provider returned generic error; check Graph app permissions and API logs)"
|
failure_detail = "Email send failed (provider returned generic error; check Graph app permissions and API logs)"
|
||||||
raise HTTPException(status_code=500, detail=f"{failure_detail} [diag:sag-send-v3]")
|
raise HTTPException(status_code=500, detail=f"{failure_detail} [diag:sag-send-v3]")
|
||||||
|
|
||||||
sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub"
|
sender_name = email_service.smtp_config.get("from_name") or "BMC Hub"
|
||||||
sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or ""
|
sender_email = (
|
||||||
|
email_service.graph_config.get("user_email")
|
||||||
|
if email_service._graph_send_available()
|
||||||
|
else email_service.smtp_config.get("from_address")
|
||||||
|
) or ""
|
||||||
provider_thread_key_normalized = _normalize_message_id_token(provider_thread_key)
|
provider_thread_key_normalized = _normalize_message_id_token(provider_thread_key)
|
||||||
|
|
||||||
# Keep replies in the existing case thread when we already know the target thread.
|
# Keep replies in the existing case thread when we already know the target thread.
|
||||||
|
|||||||
@ -159,47 +159,7 @@ def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
|
|||||||
|
|
||||||
|
|
||||||
def _fetch_case_status_options() -> list[str]:
|
def _fetch_case_status_options() -> list[str]:
|
||||||
default_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
|
return ["Ny", "Tildelt", "Igangværende", "Afventer", "Løst", "Kunde svaret", "Lukket"]
|
||||||
values = []
|
|
||||||
seen = set()
|
|
||||||
|
|
||||||
def _add(value: Optional[str]) -> None:
|
|
||||||
candidate = str(value or "").strip()
|
|
||||||
if not candidate:
|
|
||||||
return
|
|
||||||
key = candidate.lower()
|
|
||||||
if key in seen:
|
|
||||||
return
|
|
||||||
seen.add(key)
|
|
||||||
values.append(candidate)
|
|
||||||
|
|
||||||
setting_row = execute_query(
|
|
||||||
"SELECT value FROM settings WHERE key = %s",
|
|
||||||
("case_statuses",)
|
|
||||||
)
|
|
||||||
|
|
||||||
if setting_row and setting_row[0].get("value"):
|
|
||||||
try:
|
|
||||||
parsed = json.loads(setting_row[0].get("value") or "[]")
|
|
||||||
for item in parsed if isinstance(parsed, list) else []:
|
|
||||||
value = ""
|
|
||||||
if isinstance(item, str):
|
|
||||||
value = item.strip()
|
|
||||||
elif isinstance(item, dict):
|
|
||||||
value = str(item.get("value") or "").strip()
|
|
||||||
_add(value)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
|
|
||||||
|
|
||||||
for row in statuses:
|
|
||||||
_add(row.get("status"))
|
|
||||||
|
|
||||||
for default in default_statuses:
|
|
||||||
_add(default)
|
|
||||||
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_closed_case_statuses() -> list[str]:
|
def _fetch_closed_case_statuses() -> list[str]:
|
||||||
@ -571,8 +531,8 @@ async def sag_varekob_salg(request: Request):
|
|||||||
|
|
||||||
@router.get("/sag/{sag_id}", response_class=HTMLResponse)
|
@router.get("/sag/{sag_id}", response_class=HTMLResponse)
|
||||||
async def sag_detaljer(request: Request, sag_id: int):
|
async def sag_detaljer(request: Request, sag_id: int):
|
||||||
"""Redirect legacy case details URL to v3."""
|
"""Redirect the unversioned case URL to the active V4 page."""
|
||||||
return RedirectResponse(url=f"/sag/{sag_id}/v3", status_code=307)
|
return RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)
|
||||||
|
|
||||||
"""Display case details."""
|
"""Display case details."""
|
||||||
try:
|
try:
|
||||||
@ -999,9 +959,9 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to load case details")
|
raise HTTPException(status_code=500, detail="Failed to load case details")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse)
|
@router.get("/sag/{sag_id}/v4", response_class=HTMLResponse)
|
||||||
async def sag_detaljer_v3(request: Request, sag_id: int):
|
async def sag_detaljer_v4(request: Request, sag_id: int):
|
||||||
"""Display case details."""
|
"""Display the active V4 case page."""
|
||||||
try:
|
try:
|
||||||
# Fetch main case
|
# Fetch main case
|
||||||
sag_query = """
|
sag_query = """
|
||||||
@ -1339,7 +1299,8 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
|
|||||||
status_options.append(current_status)
|
status_options.append(current_status)
|
||||||
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
|
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
|
||||||
|
|
||||||
response = templates.TemplateResponse("modules/sag/templates/detail_v3.html", {
|
template_name = "modules/sag/templates/detail_v4.html"
|
||||||
|
response = templates.TemplateResponse(template_name, {
|
||||||
"request": request,
|
"request": request,
|
||||||
"case": sag,
|
"case": sag,
|
||||||
"customer": customer,
|
"customer": customer,
|
||||||
@ -1374,6 +1335,12 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to load case details")
|
raise HTTPException(status_code=500, detail="Failed to load case details")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse)
|
||||||
|
async def sag_detaljer_v3_legacy(sag_id: int):
|
||||||
|
"""Keep old links working, while making V4 the canonical case URL."""
|
||||||
|
return RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sag/{sag_id}/edit", response_class=HTMLResponse)
|
@router.get("/sag/{sag_id}/edit", response_class=HTMLResponse)
|
||||||
async def sag_rediger(request: Request, sag_id: int):
|
async def sag_rediger(request: Request, sag_id: int):
|
||||||
"""Display edit case form."""
|
"""Display edit case form."""
|
||||||
|
|||||||
@ -45,9 +45,15 @@ class RelationService:
|
|||||||
s.status,
|
s.status,
|
||||||
s.template_key,
|
s.template_key,
|
||||||
COALESCE(s.template_key, 'ticket') AS type,
|
COALESCE(s.template_key, 'ticket') AS type,
|
||||||
|
s.ansvarlig_bruger_id,
|
||||||
|
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||||
|
s.assigned_group_id,
|
||||||
|
g.name AS gruppe_navn,
|
||||||
next_todo.title AS next_todo_title,
|
next_todo.title AS next_todo_title,
|
||||||
next_todo.due_date AS next_todo_due_date
|
next_todo.due_date AS next_todo_due_date
|
||||||
FROM sag_sager s
|
FROM sag_sager s
|
||||||
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
|
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT t.title, t.due_date
|
SELECT t.title, t.due_date
|
||||||
FROM sag_todo_steps t
|
FROM sag_todo_steps t
|
||||||
|
|||||||
@ -245,7 +245,10 @@
|
|||||||
.cc-template-toolbar .form-select { min-height:36px; max-width:280px; font-size:.78rem; }
|
.cc-template-toolbar .form-select { min-height:36px; max-width:280px; font-size:.78rem; }
|
||||||
.cc-page .cc-type-section { padding:0 0 16px; margin-bottom:16px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
|
.cc-page .cc-type-section { padding:0 0 16px; margin-bottom:16px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
|
||||||
.cc-page .cc-type-section > label { display:block; margin-bottom:8px !important; text-transform:none; letter-spacing:0; color:var(--text-primary,#213547); font-weight:600; font-size:.82rem; }
|
.cc-page .cc-type-section > label { display:block; margin-bottom:8px !important; text-transform:none; letter-spacing:0; color:var(--text-primary,#213547); font-weight:600; font-size:.82rem; }
|
||||||
.cc-page #caseTypeHelp { display:none; }
|
.cc-page #caseTypeHelp { display:block; }
|
||||||
|
.cc-page .card-custom.cc-postit-mode { background:#fff5a8; border:1px solid #e6c74d; box-shadow:8px 10px 22px rgba(112,87,8,.2); }
|
||||||
|
.cc-page .cc-postit-panel { background:#fffbe0; border-color:#dfbd37; }
|
||||||
|
.cc-page .cc-postit-panel summary { background:#fff1a8; }
|
||||||
.cc-page #cc-types { gap:7px; }
|
.cc-page #cc-types { gap:7px; }
|
||||||
.cc-page #cc-types > button { flex:0 1 auto; min-height:0; padding:8px 11px; border-radius:9px; font-size:.78rem; }
|
.cc-page #cc-types > button { flex:0 1 auto; min-height:0; padding:8px 11px; border-radius:9px; font-size:.78rem; }
|
||||||
.cc-page .cc-type-name { display:inline; font-size:.78rem; }
|
.cc-page .cc-type-name { display:inline; font-size:.78rem; }
|
||||||
@ -286,11 +289,6 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<!-- Main Card -->
|
<!-- Main Card -->
|
||||||
<div class="card card-custom">
|
<div class="card card-custom">
|
||||||
<div class="card-header-custom">
|
|
||||||
<div><span class="cc-eyebrow">ET GODT STED AT STARTE</span><h1>Opret en ny sag</h1><p class="mb-0">Saml opgaven, vælg de rette personer, og tilføj det, du har brug for.</p></div>
|
|
||||||
<span class="cc-hero-icon" aria-hidden="true"><i class="bi bi-folder-plus"></i></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div id="caseTopAlerts" class="case-top-alerts d-none mb-3"></div>
|
<div id="caseTopAlerts" class="case-top-alerts d-none mb-3"></div>
|
||||||
|
|
||||||
@ -307,10 +305,6 @@
|
|||||||
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
|
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
|
||||||
<select class="form-select form-select-lg" id="type" required></select>
|
<select class="form-select form-select-lg" id="type" required></select>
|
||||||
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
|
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
|
||||||
<button type="button" id="caseMessageTypeButton" class="btn btn-outline-primary cc-message-type cc-message-fallback" onclick="window.caseCreateUI?.openMessage?.() || window.openInternalMessage?.()">
|
|
||||||
<span class="cc-type-name"><i class="bi bi-chat-square-text"></i>Besked til kollega</span>
|
|
||||||
<small class="cc-type-hint">Kort besked eller telefonbesked</small>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section: Relations -->
|
<!-- Section: Relations -->
|
||||||
@ -346,6 +340,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
|
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
|
||||||
|
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
|
||||||
|
<select class="form-select" id="ansvarlig_bruger_id">
|
||||||
|
<option value="">Ingen</option>
|
||||||
|
{% for user in assignment_users or [] %}
|
||||||
|
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
|
||||||
|
<select class="form-select" id="assigned_group_id">
|
||||||
|
<option value="">Ingen</option>
|
||||||
|
{% for group in assignment_groups or [] %}
|
||||||
|
<option value="{{ group.id }}">{{ group.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr class="my-4 opacity-25">
|
<hr class="my-4 opacity-25">
|
||||||
|
|
||||||
<!-- Section: Basic Info -->
|
<!-- Section: Basic Info -->
|
||||||
@ -365,7 +380,7 @@
|
|||||||
<i class="bi bi-magic me-1"></i>AI renskriv
|
<i class="bi bi-magic me-1"></i>AI renskriv
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
|
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..." required></textarea>
|
||||||
<div class="d-flex justify-content-between form-text">
|
<div class="d-flex justify-content-between form-text">
|
||||||
<span>AI retter kun sprog og foreslår en titel ud fra din tekst. Den må ikke tilføje oplysninger.</span>
|
<span>AI retter kun sprog og foreslår en titel ud fra din tekst. Den må ikke tilføje oplysninger.</span>
|
||||||
<span id="charCount">0 tegn</span>
|
<span id="charCount">0 tegn</span>
|
||||||
@ -375,6 +390,11 @@
|
|||||||
|
|
||||||
<hr class="my-4 opacity-25">
|
<hr class="my-4 opacity-25">
|
||||||
|
|
||||||
|
<!-- Fields that belong to the selected case type are rendered here. -->
|
||||||
|
<section id="typeDetailsSection" class="d-none"><div id="typeDetailsFields"></div></section>
|
||||||
|
|
||||||
|
<div id="typeModuleAction" class="d-flex flex-wrap gap-2 mt-4 mb-4 d-none" aria-label="Tilføj moduler"></div>
|
||||||
|
|
||||||
<!-- Section: Hardware & AnyDesk -->
|
<!-- Section: Hardware & AnyDesk -->
|
||||||
<section id="hardwareSection">
|
<section id="hardwareSection">
|
||||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
|
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
|
||||||
@ -436,10 +456,12 @@
|
|||||||
|
|
||||||
<hr class="my-4 opacity-25">
|
<hr class="my-4 opacity-25">
|
||||||
|
|
||||||
<!-- Section: Metadata -->
|
<!-- Optional metadata stays available without competing with the core flow. -->
|
||||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
|
<details id="advancedCaseFields" class="mt-4">
|
||||||
|
<summary class="small text-muted fw-semibold">Flere valg: ansvarlig, status og deadline</summary>
|
||||||
|
<div class="pt-3">
|
||||||
<div class="row g-4 mb-4">
|
<div class="row g-4 mb-4">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4 case-type-extra">
|
||||||
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
|
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
|
||||||
<select class="form-select" id="status" required>
|
<select class="form-select" id="status" required>
|
||||||
<option value="åben" selected>🟢 Åben</option>
|
<option value="åben" selected>🟢 Åben</option>
|
||||||
@ -447,28 +469,9 @@
|
|||||||
<option value="lukket">🔴 Lukket</option>
|
<option value="lukket">🔴 Lukket</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
|
||||||
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
|
|
||||||
<select class="form-select" id="ansvarlig_bruger_id">
|
|
||||||
<option value="">Ingen</option>
|
|
||||||
{% for user in assignment_users or [] %}
|
|
||||||
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4">
|
<div class="row g-4 mb-4 case-type-extra">
|
||||||
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
|
|
||||||
<select class="form-select" id="assigned_group_id">
|
|
||||||
<option value="">Ingen</option>
|
|
||||||
{% for group in assignment_groups or [] %}
|
|
||||||
<option value="{{ group.id }}">{{ group.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4 mb-4">
|
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label for="deadline" class="form-label">Deadline</label>
|
<label for="deadline" class="form-label">Deadline</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@ -477,6 +480,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="d-flex justify-content-end gap-3 mt-5">
|
<div class="d-flex justify-content-end gap-3 mt-5">
|
||||||
@ -1329,20 +1334,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const caseTypeLabels = {
|
const caseTypeLabels = {
|
||||||
ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave',
|
ticket: '🎫 Ticket', salg: '💼 Salg', bogholderi: '🧾 Bogholderi',
|
||||||
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service',
|
admin: '⚙️ Admin', projekt: '📁 Projekt', postit: '🗒️ Post-it',
|
||||||
abonnement: '🔁 Abonnement'
|
intern_ticket: '🔒 Intern ticket', bug_report: '🐞 Bug_report',
|
||||||
|
pipeline: '📈 Pipeline', opgave: '🧩 Opgave', ordre: '🧾 Ordre',
|
||||||
|
service: '🛠️ Service', abonnement: '🔁 Abonnement'
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateCaseTypeSections() {
|
function updateCaseTypeSections(showTypeFields = false) {
|
||||||
const type = document.getElementById('type')?.value || 'ticket';
|
const type = document.getElementById('type')?.value || 'ticket';
|
||||||
['hardwareSection', 'pipelineSection', 'orderSection'].forEach(id => document.getElementById(id)?.classList.remove('d-none'));
|
window.caseCreateUI?.typeChanged(type, showTypeFields);
|
||||||
window.caseCreateUI?.typeChanged(type);
|
|
||||||
const help = document.getElementById('caseTypeHelp');
|
const help = document.getElementById('caseTypeHelp');
|
||||||
if (help) help.textContent = type === 'ticket' ? 'Tilføj eventuelt hardware og AnyDesk til din ticket. Alle ekstra funktioner er tilgængelige.'
|
if (help && !showTypeFields) help.textContent = 'Vælg sagstype for at åbne de relevante felter.';
|
||||||
|
else if (help) help.textContent = type === 'ticket' ? 'Tilføj eventuelt hardware og AnyDesk til din ticket.'
|
||||||
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
|
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
|
||||||
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
|
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
|
||||||
: 'Tilføj hardware, pipeline, tags og køb/salg efter behov.';
|
: 'Tilføj de relevante oplysninger til sagen.';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOrderLinesEmptyState() {
|
function renderOrderLinesEmptyState() {
|
||||||
@ -1417,28 +1424,29 @@
|
|||||||
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
|
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
|
||||||
const profile = profileRes.ok ? await profileRes.json() : {};
|
const profile = profileRes.ok ? await profileRes.json() : {};
|
||||||
const configured = JSON.parse(setting.value || '[]');
|
const configured = JSON.parse(setting.value || '[]');
|
||||||
const types = Array.isArray(configured) && configured.length ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : Object.keys(caseTypeLabels);
|
const configuredTypes = Array.isArray(configured) ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : [];
|
||||||
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
|
const supportedTypes = ['ticket', 'postit', 'salg', 'bogholderi', 'admin', 'projekt', 'intern_ticket', 'bug_report'];
|
||||||
if (!types.includes('abonnement')) types.push('abonnement');
|
const finalTypes = supportedTypes;
|
||||||
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
|
|
||||||
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
|
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
|
||||||
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
|
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load case types', err);
|
console.error('Failed to load case types', err);
|
||||||
select.innerHTML = Object.entries(caseTypeLabels).map(([type, label]) => `<option value="${type}">${label}</option>`).join('');
|
select.innerHTML = Object.entries(caseTypeLabels).map(([type, label]) => `<option value="${type}">${label}</option>`).join('');
|
||||||
}
|
}
|
||||||
updateCaseTypeSections();
|
updateCaseTypeSections(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectCurrentUserAsResponsible() {
|
async function selectCurrentUserAsResponsible() {
|
||||||
const select = document.getElementById('ansvarlig_bruger_id');
|
const select = document.getElementById('ansvarlig_bruger_id');
|
||||||
if (!select || select.value) return;
|
if (!select) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/auth/me', { credentials: 'include' });
|
const response = await fetch('/api/v1/auth/me', { credentials: 'include' });
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const user = await response.json();
|
const user = await response.json();
|
||||||
const userId = user.user_id ?? user.id;
|
const userId = user.user_id ?? user.id;
|
||||||
if (userId && Array.from(select.options).some(option => option.value === String(userId))) {
|
window.caseCreateCurrentUserId = userId || null;
|
||||||
|
const isInternalTicket = document.getElementById('type')?.value === 'intern_ticket';
|
||||||
|
if (userId && (isInternalTicket || !select.value) && Array.from(select.options).some(option => option.value === String(userId))) {
|
||||||
select.value = String(userId);
|
select.value = String(userId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -1450,7 +1458,10 @@
|
|||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
initializeSearch();
|
initializeSearch();
|
||||||
await Promise.all([loadCaseTypesSelect(), loadPipelineStages(), selectCurrentUserAsResponsible()]);
|
await Promise.all([loadCaseTypesSelect(), loadPipelineStages(), selectCurrentUserAsResponsible()]);
|
||||||
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
|
// Both the selected type and current user are now known. Reapply the
|
||||||
|
// type defaults so Intern ticket cannot display a stale assignee.
|
||||||
|
updateCaseTypeSections(false);
|
||||||
|
document.getElementById('type')?.addEventListener('change', () => updateCaseTypeSections(false));
|
||||||
await applyTelefoniPrefill();
|
await applyTelefoniPrefill();
|
||||||
await window.caseCreateUI?.init();
|
await window.caseCreateUI?.init();
|
||||||
});
|
});
|
||||||
@ -1566,7 +1577,7 @@
|
|||||||
|
|
||||||
successDiv.classList.remove('d-none');
|
successDiv.classList.remove('d-none');
|
||||||
document.getElementById('success-text').textContent = "Sag oprettet";
|
document.getElementById('success-text').textContent = "Sag oprettet";
|
||||||
window.location.href = `/sag/${result.id}/v3`;
|
window.location.href = `/sag/${result.id}/v4`;
|
||||||
} else {
|
} else {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
let errMsg = "Kunne ikke oprette sag";
|
let errMsg = "Kunne ikke oprette sag";
|
||||||
@ -1586,5 +1597,5 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/case-create.js?v=5"></script>
|
<script src="/static/js/case-create.js?v=13"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
24310
app/modules/sag/templates/detail_v4.html
Normal file
24310
app/modules/sag/templates/detail_v4.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -148,7 +148,7 @@ class TelefoniService:
|
|||||||
pending_rows = execute_query(
|
pending_rows = execute_query(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM telefoni_opkald
|
SELECT * FROM telefoni_opkald
|
||||||
WHERE callid LIKE 'click-to-call:%'
|
WHERE callid LIKE 'click-to-call:%%'
|
||||||
AND direction = 'outbound'
|
AND direction = 'outbound'
|
||||||
AND bruger_id = %s
|
AND bruger_id = %s
|
||||||
AND ended_at IS NULL
|
AND ended_at IS NULL
|
||||||
|
|||||||
@ -34,8 +34,9 @@ from aiohttp import ClientSession, BasicAuth
|
|||||||
import msal
|
import msal
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.crypto import decrypt_secret
|
||||||
from app.services.system_event_logger import log_system_event
|
from app.services.system_event_logger import log_system_event
|
||||||
from app.core.database import execute_query, execute_insert, execute_update
|
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -60,6 +61,78 @@ class EmailService:
|
|||||||
'client_secret': settings.GRAPH_CLIENT_SECRET,
|
'client_secret': settings.GRAPH_CLIENT_SECRET,
|
||||||
'user_email': settings.GRAPH_USER_EMAIL
|
'user_email': settings.GRAPH_USER_EMAIL
|
||||||
}
|
}
|
||||||
|
self.smtp_config = {
|
||||||
|
'host': settings.EMAIL_SMTP_HOST,
|
||||||
|
'port': settings.EMAIL_SMTP_PORT,
|
||||||
|
'username': settings.EMAIL_SMTP_USER,
|
||||||
|
'password': settings.EMAIL_SMTP_PASSWORD,
|
||||||
|
'use_tls': settings.EMAIL_SMTP_USE_TLS,
|
||||||
|
'from_address': settings.EMAIL_SMTP_FROM_ADDRESS,
|
||||||
|
'from_name': settings.EMAIL_SMTP_FROM_NAME,
|
||||||
|
}
|
||||||
|
self._apply_saved_connection()
|
||||||
|
|
||||||
|
def _apply_saved_connection(self) -> None:
|
||||||
|
"""Use the one Settings mailbox when enabled, otherwise keep environment compatibility."""
|
||||||
|
try:
|
||||||
|
row = execute_query_single(
|
||||||
|
"SELECT * FROM email_connection_configurations WHERE is_enabled = true ORDER BY id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# Older installations can run before migration 1035. Their .env setup remains authoritative.
|
||||||
|
logger.debug("Email connection settings unavailable; using environment configuration: %s", exc)
|
||||||
|
return
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_secrets = decrypt_secret(str(row.get('secrets_encrypted') or ''))
|
||||||
|
if not raw_secrets:
|
||||||
|
logger.error("Enabled email connection has no readable credentials; using environment configuration")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
saved_secrets = json.loads(raw_secrets)
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
logger.error("Enabled email connection has invalid encrypted credentials; using environment configuration")
|
||||||
|
return
|
||||||
|
|
||||||
|
mailbox = str(row.get('mailbox_address') or '').strip()
|
||||||
|
if row.get('provider') == 'microsoft365_graph':
|
||||||
|
required = ('graph_tenant_id', 'graph_client_id', 'graph_client_secret')
|
||||||
|
if not all((row.get('graph_tenant_id'), row.get('graph_client_id'), saved_secrets.get('graph_client_secret'))):
|
||||||
|
logger.error("Enabled Microsoft 365 email connection is incomplete; using environment configuration")
|
||||||
|
return
|
||||||
|
self.use_graph = True
|
||||||
|
self.graph_config = {
|
||||||
|
'tenant_id': row.get('graph_tenant_id'),
|
||||||
|
'client_id': row.get('graph_client_id'),
|
||||||
|
'client_secret': saved_secrets['graph_client_secret'],
|
||||||
|
'user_email': mailbox,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
if not all((row.get('imap_host'), row.get('smtp_host'), saved_secrets.get('imap_password'), saved_secrets.get('smtp_password'))):
|
||||||
|
logger.error("Enabled IMAP/SMTP email connection is incomplete; using environment configuration")
|
||||||
|
return
|
||||||
|
self.use_graph = False
|
||||||
|
self.imap_config = {
|
||||||
|
'server': row.get('imap_host'),
|
||||||
|
'port': row.get('imap_port') or 993,
|
||||||
|
'username': mailbox,
|
||||||
|
'password': saved_secrets['imap_password'],
|
||||||
|
'use_ssl': bool(row.get('imap_use_ssl')),
|
||||||
|
'folder': row.get('imap_folder') or 'INBOX',
|
||||||
|
'readonly': True,
|
||||||
|
}
|
||||||
|
# Outbound SMTP is read by the sender further down via settings. Keep config local as well.
|
||||||
|
self.smtp_config = {
|
||||||
|
'host': row.get('smtp_host'),
|
||||||
|
'port': row.get('smtp_port') or 587,
|
||||||
|
'username': mailbox,
|
||||||
|
'password': saved_secrets['smtp_password'],
|
||||||
|
'use_tls': bool(row.get('smtp_use_tls')),
|
||||||
|
'from_address': mailbox,
|
||||||
|
'from_name': row.get('sender_name') or 'BMC Hub',
|
||||||
|
}
|
||||||
|
|
||||||
def _graph_send_available(self) -> bool:
|
def _graph_send_available(self) -> bool:
|
||||||
return bool(
|
return bool(
|
||||||
@ -1789,7 +1862,7 @@ class EmailService:
|
|||||||
return False, "aiosmtplib not installed"
|
return False, "aiosmtplib not installed"
|
||||||
|
|
||||||
# Validate SMTP configuration
|
# Validate SMTP configuration
|
||||||
if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]):
|
if not all([self.smtp_config['host'], self.smtp_config['username'], self.smtp_config['password']]):
|
||||||
logger.error("❌ SMTP not configured - cannot send email")
|
logger.error("❌ SMTP not configured - cannot send email")
|
||||||
if graph_failure_message:
|
if graph_failure_message:
|
||||||
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured"
|
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured"
|
||||||
@ -1799,7 +1872,7 @@ class EmailService:
|
|||||||
# Build message
|
# Build message
|
||||||
msg = MIMEMultipart('alternative')
|
msg = MIMEMultipart('alternative')
|
||||||
msg['Subject'] = subject
|
msg['Subject'] = subject
|
||||||
msg['From'] = f"{settings.EMAIL_SMTP_FROM_NAME} <{settings.EMAIL_SMTP_FROM_ADDRESS}>"
|
msg['From'] = f"{self.smtp_config['from_name']} <{self.smtp_config['from_address']}>"
|
||||||
msg['To'] = ', '.join(to_addresses)
|
msg['To'] = ', '.join(to_addresses)
|
||||||
|
|
||||||
if cc:
|
if cc:
|
||||||
@ -1817,11 +1890,11 @@ class EmailService:
|
|||||||
|
|
||||||
# Send via SMTP
|
# Send via SMTP
|
||||||
async with aiosmtplib.SMTP(
|
async with aiosmtplib.SMTP(
|
||||||
hostname=settings.EMAIL_SMTP_HOST,
|
hostname=self.smtp_config['host'],
|
||||||
port=settings.EMAIL_SMTP_PORT,
|
port=self.smtp_config['port'],
|
||||||
use_tls=settings.EMAIL_SMTP_USE_TLS
|
use_tls=self.smtp_config['use_tls']
|
||||||
) as smtp:
|
) as smtp:
|
||||||
await smtp.login(settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD)
|
await smtp.login(self.smtp_config['username'], self.smtp_config['password'])
|
||||||
|
|
||||||
# Combine all recipients
|
# Combine all recipients
|
||||||
all_recipients = to_addresses.copy()
|
all_recipients = to_addresses.copy()
|
||||||
@ -1831,7 +1904,7 @@ class EmailService:
|
|||||||
all_recipients.extend(bcc)
|
all_recipients.extend(bcc)
|
||||||
|
|
||||||
await smtp.sendmail(
|
await smtp.sendmail(
|
||||||
settings.EMAIL_SMTP_FROM_ADDRESS,
|
self.smtp_config['from_address'],
|
||||||
all_recipients,
|
all_recipients,
|
||||||
msg.as_string()
|
msg.as_string()
|
||||||
)
|
)
|
||||||
@ -1928,7 +2001,7 @@ class EmailService:
|
|||||||
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: aiosmtplib not installed", generated_message_id, provider_thread_key
|
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: aiosmtplib not installed", generated_message_id, provider_thread_key
|
||||||
return False, "aiosmtplib not installed", generated_message_id, provider_thread_key
|
return False, "aiosmtplib not installed", generated_message_id, provider_thread_key
|
||||||
|
|
||||||
if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]):
|
if not all([self.smtp_config['host'], self.smtp_config['username'], self.smtp_config['password']]):
|
||||||
logger.error("❌ SMTP not configured - cannot send email")
|
logger.error("❌ SMTP not configured - cannot send email")
|
||||||
if graph_failure_message:
|
if graph_failure_message:
|
||||||
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured", generated_message_id, provider_thread_key
|
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured", generated_message_id, provider_thread_key
|
||||||
@ -1937,7 +2010,7 @@ class EmailService:
|
|||||||
try:
|
try:
|
||||||
msg = MIMEMultipart('mixed')
|
msg = MIMEMultipart('mixed')
|
||||||
msg['Subject'] = subject
|
msg['Subject'] = subject
|
||||||
msg['From'] = f"{settings.EMAIL_SMTP_FROM_NAME} <{settings.EMAIL_SMTP_FROM_ADDRESS}>"
|
msg['From'] = f"{self.smtp_config['from_name']} <{self.smtp_config['from_address']}>"
|
||||||
msg['To'] = ', '.join(to_addresses)
|
msg['To'] = ', '.join(to_addresses)
|
||||||
msg['Message-ID'] = generated_message_id
|
msg['Message-ID'] = generated_message_id
|
||||||
|
|
||||||
@ -1974,11 +2047,11 @@ class EmailService:
|
|||||||
msg.attach(mime_attachment)
|
msg.attach(mime_attachment)
|
||||||
|
|
||||||
async with aiosmtplib.SMTP(
|
async with aiosmtplib.SMTP(
|
||||||
hostname=settings.EMAIL_SMTP_HOST,
|
hostname=self.smtp_config['host'],
|
||||||
port=settings.EMAIL_SMTP_PORT,
|
port=self.smtp_config['port'],
|
||||||
use_tls=settings.EMAIL_SMTP_USE_TLS
|
use_tls=self.smtp_config['use_tls']
|
||||||
) as smtp:
|
) as smtp:
|
||||||
await smtp.login(settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD)
|
await smtp.login(self.smtp_config['username'], self.smtp_config['password'])
|
||||||
|
|
||||||
all_recipients = to_addresses.copy()
|
all_recipients = to_addresses.copy()
|
||||||
if cc:
|
if cc:
|
||||||
@ -1987,7 +2060,7 @@ class EmailService:
|
|||||||
all_recipients.extend(bcc)
|
all_recipients.extend(bcc)
|
||||||
|
|
||||||
await smtp.sendmail(
|
await smtp.sendmail(
|
||||||
settings.EMAIL_SMTP_FROM_ADDRESS,
|
self.smtp_config['from_address'],
|
||||||
all_recipients,
|
all_recipients,
|
||||||
msg.as_string()
|
msg.as_string()
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -3,16 +3,18 @@ Settings and User Management API Router
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends, Query
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends, Query
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict, Literal
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from app.core.database import execute_query, execute_query_single
|
from app.core.database import execute_query, execute_query_single
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.auth_dependencies import require_any_permission, require_superadmin
|
from app.core.auth_dependencies import require_any_permission, require_superadmin
|
||||||
from app.core.auth_service import AuthService
|
from app.core.auth_service import AuthService
|
||||||
|
from app.core.crypto import decrypt_secret, encrypt_secret
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
|
import imaplib
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
@ -20,6 +22,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -75,6 +78,52 @@ class AIBenchmarkRunRequest(BaseModel):
|
|||||||
test_keys: Optional[List[str]] = None
|
test_keys: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EmailConnectionUpdate(BaseModel):
|
||||||
|
provider: Literal["microsoft365_graph", "imap_smtp"]
|
||||||
|
is_enabled: bool = False
|
||||||
|
mailbox_address: str
|
||||||
|
sender_name: str = "BMC Hub"
|
||||||
|
imap_host: Optional[str] = ""
|
||||||
|
imap_port: int = 993
|
||||||
|
imap_folder: str = "INBOX"
|
||||||
|
imap_use_ssl: bool = True
|
||||||
|
smtp_host: Optional[str] = ""
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_use_tls: bool = True
|
||||||
|
graph_tenant_id: Optional[str] = ""
|
||||||
|
graph_client_id: Optional[str] = ""
|
||||||
|
imap_password: Optional[str] = None
|
||||||
|
smtp_password: Optional[str] = None
|
||||||
|
graph_client_secret: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _email_connection_public(row: Optional[Dict]) -> Dict:
|
||||||
|
"""Return connection metadata only; encrypted credentials never leave the server."""
|
||||||
|
if not row:
|
||||||
|
return {"configured": False, "source": "environment"}
|
||||||
|
encrypted = str(row.get("secrets_encrypted") or "").strip()
|
||||||
|
return {
|
||||||
|
"configured": True,
|
||||||
|
"source": "settings",
|
||||||
|
"provider": row["provider"],
|
||||||
|
"is_enabled": bool(row["is_enabled"]),
|
||||||
|
"mailbox_address": row["mailbox_address"],
|
||||||
|
"sender_name": row.get("sender_name") or "BMC Hub",
|
||||||
|
"imap_host": row.get("imap_host") or "",
|
||||||
|
"imap_port": row.get("imap_port") or 993,
|
||||||
|
"imap_folder": row.get("imap_folder") or "INBOX",
|
||||||
|
"imap_use_ssl": bool(row.get("imap_use_ssl")),
|
||||||
|
"smtp_host": row.get("smtp_host") or "",
|
||||||
|
"smtp_port": row.get("smtp_port") or 587,
|
||||||
|
"smtp_use_tls": bool(row.get("smtp_use_tls")),
|
||||||
|
"graph_tenant_id": row.get("graph_tenant_id") or "",
|
||||||
|
"graph_client_id": row.get("graph_client_id") or "",
|
||||||
|
"has_saved_credentials": bool(encrypted),
|
||||||
|
"inbound_processing_enabled": bool(settings.EMAIL_TO_TICKET_ENABLED),
|
||||||
|
"updated_at": row.get("updated_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
MATTERMOST_SETTING_DEFAULTS = (
|
MATTERMOST_SETTING_DEFAULTS = (
|
||||||
("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"),
|
("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"),
|
||||||
("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"),
|
("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"),
|
||||||
@ -273,6 +322,177 @@ async def create_ai_benchmark_run(
|
|||||||
|
|
||||||
|
|
||||||
# Settings Endpoints
|
# Settings Endpoints
|
||||||
|
@router.get("/settings/email-connection", tags=["Settings"])
|
||||||
|
async def get_email_connection(current_user: dict = Depends(require_superadmin)):
|
||||||
|
"""Read mailbox setup metadata without exposing any password or client secret."""
|
||||||
|
row = execute_query_single(
|
||||||
|
"SELECT * FROM email_connection_configurations ORDER BY id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
return _email_connection_public(row)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"source": "environment",
|
||||||
|
"provider": "microsoft365_graph" if settings.USE_GRAPH_API else "imap_smtp",
|
||||||
|
"is_enabled": bool(
|
||||||
|
settings.GRAPH_USER_EMAIL if settings.USE_GRAPH_API else settings.IMAP_USERNAME
|
||||||
|
),
|
||||||
|
"mailbox_address": settings.GRAPH_USER_EMAIL if settings.USE_GRAPH_API else settings.IMAP_USERNAME,
|
||||||
|
"sender_name": settings.EMAIL_SMTP_FROM_NAME or "BMC Hub",
|
||||||
|
"has_saved_credentials": bool(
|
||||||
|
settings.GRAPH_CLIENT_SECRET if settings.USE_GRAPH_API else settings.IMAP_PASSWORD
|
||||||
|
),
|
||||||
|
"inbound_processing_enabled": bool(settings.EMAIL_TO_TICKET_ENABLED),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings/email-connection", tags=["Settings"])
|
||||||
|
async def save_email_connection(
|
||||||
|
payload: EmailConnectionUpdate,
|
||||||
|
current_user: dict = Depends(require_superadmin),
|
||||||
|
):
|
||||||
|
"""Save one encrypted mailbox configuration used for both inbound and outbound mail."""
|
||||||
|
mailbox = payload.mailbox_address.strip().lower()
|
||||||
|
if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", mailbox):
|
||||||
|
raise HTTPException(status_code=400, detail="Indtast en gyldig postkasseadresse")
|
||||||
|
if not (1 <= payload.imap_port <= 65535 and 1 <= payload.smtp_port <= 65535):
|
||||||
|
raise HTTPException(status_code=400, detail="Port skal være mellem 1 og 65535")
|
||||||
|
|
||||||
|
existing = execute_query_single(
|
||||||
|
"SELECT * FROM email_connection_configurations ORDER BY id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
prior_secrets = (existing or {}).get("secrets_encrypted")
|
||||||
|
saved_secrets = {}
|
||||||
|
if prior_secrets:
|
||||||
|
decoded = decrypt_secret(str(prior_secrets))
|
||||||
|
if decoded:
|
||||||
|
try:
|
||||||
|
saved_secrets = json.loads(decoded)
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
raise HTTPException(status_code=500, detail="De gemte e-mailadgangsoplysninger kan ikke læses. Indtast dem igen.")
|
||||||
|
supplied_secrets = {
|
||||||
|
key: value.strip()
|
||||||
|
for key, value in {
|
||||||
|
"imap_password": payload.imap_password,
|
||||||
|
"smtp_password": payload.smtp_password,
|
||||||
|
"graph_client_secret": payload.graph_client_secret,
|
||||||
|
}.items()
|
||||||
|
if value is not None and value.strip()
|
||||||
|
}
|
||||||
|
saved_secrets.update(supplied_secrets)
|
||||||
|
secrets_encrypted = prior_secrets
|
||||||
|
if supplied_secrets or (prior_secrets and not saved_secrets):
|
||||||
|
try:
|
||||||
|
secrets_encrypted = encrypt_secret(json.dumps(saved_secrets))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Sikker lagring er ikke klar. Sæt NEXTCLOUD_ENCRYPTION_KEY på serveren før adgangsoplysninger gemmes.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.provider == "microsoft365_graph":
|
||||||
|
if payload.is_enabled and not (payload.graph_tenant_id.strip() and payload.graph_client_id.strip() and saved_secrets.get("graph_client_secret")):
|
||||||
|
raise HTTPException(status_code=400, detail="Microsoft 365 kræver Tenant ID, Application ID og client secret før aktivering")
|
||||||
|
elif payload.is_enabled and not (
|
||||||
|
payload.imap_host.strip() and payload.smtp_host.strip()
|
||||||
|
and saved_secrets.get("imap_password") and saved_secrets.get("smtp_password")
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=400, detail="IMAP/SMTP kræver begge servere og adgangskoder før aktivering")
|
||||||
|
|
||||||
|
user_id = current_user.get("id") or current_user.get("user_id")
|
||||||
|
values = (
|
||||||
|
payload.provider, payload.is_enabled, mailbox, payload.sender_name.strip() or "BMC Hub",
|
||||||
|
payload.imap_host.strip() or None, payload.imap_port, payload.imap_folder.strip() or "INBOX", payload.imap_use_ssl,
|
||||||
|
payload.smtp_host.strip() or None, payload.smtp_port, payload.smtp_use_tls,
|
||||||
|
payload.graph_tenant_id.strip() or None, payload.graph_client_id.strip() or None,
|
||||||
|
secrets_encrypted, user_id,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
query = """
|
||||||
|
UPDATE email_connection_configurations SET
|
||||||
|
provider=%s, is_enabled=%s, mailbox_address=%s, sender_name=%s,
|
||||||
|
imap_host=%s, imap_port=%s, imap_folder=%s, imap_use_ssl=%s,
|
||||||
|
smtp_host=%s, smtp_port=%s, smtp_use_tls=%s,
|
||||||
|
graph_tenant_id=%s, graph_client_id=%s, secrets_encrypted=%s,
|
||||||
|
updated_by_user_id=%s, updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=%s RETURNING *
|
||||||
|
"""
|
||||||
|
result = execute_query(query, (*values, existing["id"]))
|
||||||
|
else:
|
||||||
|
query = """
|
||||||
|
INSERT INTO email_connection_configurations
|
||||||
|
(provider, is_enabled, mailbox_address, sender_name, imap_host, imap_port, imap_folder, imap_use_ssl,
|
||||||
|
smtp_host, smtp_port, smtp_use_tls, graph_tenant_id, graph_client_id, secrets_encrypted, updated_by_user_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *
|
||||||
|
"""
|
||||||
|
result = execute_query(query, values)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=500, detail="Kunne ikke gemme e-mailopsætningen")
|
||||||
|
return _email_connection_public(result[0])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/email-connection/test", tags=["Settings"])
|
||||||
|
async def test_email_connection(current_user: dict = Depends(require_superadmin)):
|
||||||
|
"""Verify authentication and inbox access without sending or changing any email."""
|
||||||
|
from app.services.email_service import EmailService, HAS_AIOSMTPLIB, aiosmtplib
|
||||||
|
|
||||||
|
configured = execute_query_single(
|
||||||
|
"SELECT provider, is_enabled FROM email_connection_configurations ORDER BY id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
if not configured or not configured.get("is_enabled"):
|
||||||
|
raise HTTPException(status_code=400, detail="Gem og aktivér en e-mailopsætning før du tester")
|
||||||
|
|
||||||
|
service = EmailService()
|
||||||
|
if configured["provider"] == "microsoft365_graph":
|
||||||
|
token = await service._get_graph_access_token()
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=502, detail="Microsoft 365 kunne ikke udstede et adgangstoken. Tjek Tenant ID, Application ID og client secret.")
|
||||||
|
mailbox = service.graph_config.get("user_email")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"https://graph.microsoft.com/v1.0/users/{quote(mailbox)}/mailFolders/inbox?$top=1",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise HTTPException(status_code=502, detail="Microsoft 365-token virker, men postkassen kan ikke læses. Tjek Mail.ReadWrite og admin consent.")
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Kunne ikke kontakte Microsoft Graph: {exc}")
|
||||||
|
return {"ok": True, "provider": "Microsoft 365 / Graph", "message": "Token og læseadgang til Indbakke virker. Der er ikke sendt en e-mail."}
|
||||||
|
|
||||||
|
def test_imap() -> None:
|
||||||
|
config = service.imap_config
|
||||||
|
connection = imaplib.IMAP4_SSL(config["server"], config["port"], timeout=15) if config["use_ssl"] else imaplib.IMAP4(config["server"], config["port"], timeout=15)
|
||||||
|
try:
|
||||||
|
connection.login(config["username"], config["password"])
|
||||||
|
status, _ = connection.select(config["folder"], readonly=True)
|
||||||
|
if status != "OK":
|
||||||
|
raise RuntimeError(f"kunne ikke åbne mappen {config['folder']}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
connection.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(test_imap)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"IMAP-test fejlede: {exc}")
|
||||||
|
|
||||||
|
if not HAS_AIOSMTPLIB:
|
||||||
|
raise HTTPException(status_code=503, detail="IMAP virker, men SMTP-test er ikke mulig fordi aiosmtplib mangler på serveren")
|
||||||
|
try:
|
||||||
|
config = service.smtp_config
|
||||||
|
async with aiosmtplib.SMTP(
|
||||||
|
hostname=config["host"], port=config["port"], use_tls=config["use_tls"], timeout=15
|
||||||
|
) as smtp:
|
||||||
|
await smtp.login(config["username"], config["password"])
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"SMTP-test fejlede: {exc}")
|
||||||
|
return {"ok": True, "provider": "IMAP + SMTP", "message": "IMAP-indbakke og SMTP-login virker. Der er ikke sendt en e-mail."}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings", response_model=List[Setting], tags=["Settings"])
|
@router.get("/settings", response_model=List[Setting], tags=["Settings"])
|
||||||
async def get_settings(category: Optional[str] = None):
|
async def get_settings(category: Optional[str] = None):
|
||||||
"""Get all settings or filter by category"""
|
"""Get all settings or filter by category"""
|
||||||
@ -375,7 +595,7 @@ async def get_setting(key: str):
|
|||||||
seed_query,
|
seed_query,
|
||||||
(
|
(
|
||||||
"case_types",
|
"case_types",
|
||||||
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service", "abonnement"]',
|
'["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"]',
|
||||||
"system",
|
"system",
|
||||||
"Sags-typer",
|
"Sags-typer",
|
||||||
"json",
|
"json",
|
||||||
|
|||||||
@ -171,6 +171,9 @@
|
|||||||
<a class="nav-link" href="#notifications" data-tab="notifications">
|
<a class="nav-link" href="#notifications" data-tab="notifications">
|
||||||
<i class="bi bi-bell me-2"></i>Notifikationer
|
<i class="bi bi-bell me-2"></i>Notifikationer
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-link" href="#email" data-tab="email">
|
||||||
|
<i class="bi bi-envelope-at me-2"></i>E-mail
|
||||||
|
</a>
|
||||||
<a class="nav-link" href="#users" data-tab="users">
|
<a class="nav-link" href="#users" data-tab="users">
|
||||||
<i class="bi bi-people me-2"></i>Brugere
|
<i class="bi bi-people me-2"></i>Brugere
|
||||||
</a>
|
</a>
|
||||||
@ -604,6 +607,84 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Email connection -->
|
||||||
|
<div class="tab-pane fade" id="email">
|
||||||
|
<div class="card p-4 mb-4 border-0 shadow-sm">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h5 class="fw-bold mb-1">Postkasse til sager</h5>
|
||||||
|
<p class="text-muted mb-0">Den valgte postkasse bruges både til at hente indgående mails og sende svar fra BMC Hub.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge text-bg-secondary" id="emailConnectionState">Indlæser…</span>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info small mt-4 mb-0">
|
||||||
|
<i class="bi bi-shield-lock me-2"></i>Adgangskoder og client secrets krypteres på serveren og vises aldrig igen. Lad et hemmeligt felt stå tomt for at beholde den gemte værdi.
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning small mt-3 mb-0 d-none" id="emailInboundDisabled">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>Automatisk hentning af indgående mails er slået fra på serveren. Opsætningen kan gemmes, men <code>EMAIL_TO_TICKET_ENABLED=true</code> skal aktiveres ved deployment, før nye mails hentes automatisk.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4 mb-4">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label" for="emailProvider">Udbyder</label>
|
||||||
|
<select class="form-select" id="emailProvider" onchange="toggleEmailProviderFields()">
|
||||||
|
<option value="microsoft365_graph">Microsoft 365 / Office 365 (Graph – anbefalet)</option>
|
||||||
|
<option value="imap_smtp">IMAP + SMTP (Google, webhotel eller anden udbyder)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="emailMailboxAddress">Postkasseadresse</label>
|
||||||
|
<input type="email" class="form-control" id="emailMailboxAddress" placeholder="support@firma.dk" autocomplete="email">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label" for="emailSenderName">Afsendernavn</label>
|
||||||
|
<input type="text" class="form-control" id="emailSenderName" placeholder="BMC Support">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mt-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="emailConnectionEnabled">
|
||||||
|
<label class="form-check-label" for="emailConnectionEnabled">Brug denne postkasse nu</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4 mb-4" id="emailGraphFields">
|
||||||
|
<h6 class="fw-bold mb-1">Microsoft 365 via Microsoft Graph</h6>
|
||||||
|
<p class="small text-muted mb-3">Robust løsning til en delt postkasse eller en rigtig support-postkasse. BMC Hub bruger den samme adresse ind og ud.</p>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6"><label class="form-label" for="emailGraphTenantId">Directory (tenant) ID</label><input class="form-control" id="emailGraphTenantId" autocomplete="off"></div>
|
||||||
|
<div class="col-md-6"><label class="form-label" for="emailGraphClientId">Application (client) ID</label><input class="form-control" id="emailGraphClientId" autocomplete="off"></div>
|
||||||
|
<div class="col-md-6"><label class="form-label" for="emailGraphClientSecret">Client secret</label><input type="password" class="form-control" id="emailGraphClientSecret" autocomplete="new-password"><div class="form-text">Vises kun ved indtastning; en gemt secret kan ikke læses ud igen.</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4 mb-4 d-none" id="emailImapFields">
|
||||||
|
<h6 class="fw-bold mb-3">IMAP til modtagelse og SMTP til afsendelse</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-5"><label class="form-label" for="emailImapHost">IMAP-server</label><input class="form-control" id="emailImapHost" placeholder="imap.firma.dk"></div>
|
||||||
|
<div class="col-md-2"><label class="form-label" for="emailImapPort">Port</label><input type="number" min="1" max="65535" class="form-control" id="emailImapPort" value="993"></div>
|
||||||
|
<div class="col-md-3"><label class="form-label" for="emailImapFolder">Mappe</label><input class="form-control" id="emailImapFolder" value="INBOX"></div>
|
||||||
|
<div class="col-md-2 d-flex align-items-end"><div class="form-check mb-2"><input class="form-check-input" type="checkbox" id="emailImapSsl" checked><label class="form-check-label" for="emailImapSsl">SSL/TLS</label></div></div>
|
||||||
|
<div class="col-md-6"><label class="form-label" for="emailImapPassword">IMAP-adgangskode / app-adgangskode</label><input type="password" class="form-control" id="emailImapPassword" autocomplete="new-password"></div>
|
||||||
|
<div class="col-md-5"><label class="form-label" for="emailSmtpHost">SMTP-server</label><input class="form-control" id="emailSmtpHost" placeholder="smtp.firma.dk"></div>
|
||||||
|
<div class="col-md-2"><label class="form-label" for="emailSmtpPort">Port</label><input type="number" min="1" max="65535" class="form-control" id="emailSmtpPort" value="587"></div>
|
||||||
|
<div class="col-md-3 d-flex align-items-end"><div class="form-check mb-2"><input class="form-check-input" type="checkbox" id="emailSmtpTls" checked><label class="form-check-label" for="emailSmtpTls">STARTTLS/TLS</label></div></div>
|
||||||
|
<div class="col-md-6"><label class="form-label" for="emailSmtpPassword">SMTP-adgangskode / app-adgangskode</label><input type="password" class="form-control" id="emailSmtpPassword" autocomplete="new-password"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-3 mb-5 flex-wrap"><button class="btn btn-primary" type="button" onclick="saveEmailConnection(this)"><i class="bi bi-shield-check me-2"></i>Gem e-mailopsætning</button><button class="btn btn-outline-primary" type="button" onclick="testEmailConnection(this)"><i class="bi bi-plug me-2"></i>Test forbindelse</button><span class="small" id="emailConnectionFeedback"></span></div>
|
||||||
|
|
||||||
|
<div class="card p-4">
|
||||||
|
<h5 class="fw-bold mb-3">Opsætningsguide</h5>
|
||||||
|
<div class="accordion" id="emailSetupGuide">
|
||||||
|
<div class="accordion-item"><h2 class="accordion-header"><button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#emailGuideM365">Microsoft 365 / Office 365 (anbefalet)</button></h2><div id="emailGuideM365" class="accordion-collapse collapse show" data-bs-parent="#emailSetupGuide"><div class="accordion-body small"><ol class="mb-0"><li>Opret eller vælg postkassen, fx <code>support@firma.dk</code>. Ved delt postkasse skal den kunne sende/modtage som adressen.</li><li>Gå til <strong>Microsoft Entra ID → App registrations → New registration</strong> og opret appen.</li><li>Kopiér <strong>Directory (tenant) ID</strong> og <strong>Application (client) ID</strong> ind her.</li><li>Under <strong>Certificates & secrets</strong> opret en client secret og indsæt dens <em>Value</em> med det samme.</li><li>Under <strong>API permissions → Microsoft Graph → Application permissions</strong>: tilføj <code>Mail.ReadWrite</code> og <code>Mail.Send</code>, og vælg <strong>Grant admin consent</strong>.</li><li>Gem og aktivér først derefter postkassen her. Begræns gerne appen til den valgte postkasse med Exchange application access policy.</li></ol></div></div></div>
|
||||||
|
<div class="accordion-item"><h2 class="accordion-header"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#emailGuideGoogle">Google Workspace / Gmail</button></h2><div id="emailGuideGoogle" class="accordion-collapse collapse" data-bs-parent="#emailSetupGuide"><div class="accordion-body small"><ol class="mb-0"><li>Vælg <strong>IMAP + SMTP</strong> ovenfor.</li><li>Aktivér IMAP på postkassen i Gmail/Workspace, hvis administratoren tillader det.</li><li>Slå 2-trinsbekræftelse til på postkassen og opret en <strong>app-adgangskode</strong> til Mail. Brug aldrig den normale Google-adgangskode.</li><li>Indtast <code>imap.gmail.com</code>, port <code>993</code>, SSL/TLS og <code>smtp.gmail.com</code>, port <code>587</code>, STARTTLS.</li><li>Brug postkasseadressen som afsender og samme app-adgangskode i begge hemmelige felter. Gem og aktivér.</li></ol></div></div></div>
|
||||||
|
<div class="accordion-item"><h2 class="accordion-header"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#emailGuideImap">Anden IMAP/SMTP-udbyder</button></h2><div id="emailGuideImap" class="accordion-collapse collapse" data-bs-parent="#emailSetupGuide"><div class="accordion-body small"><ol class="mb-0"><li>Find IMAP- og SMTP-server, port og sikkerhedskrav hos udbyderen.</li><li>Typiske værdier er IMAP <code>993</code> med SSL/TLS og SMTP <code>587</code> med STARTTLS.</li><li>Opret helst en særskilt app-adgangskode eller servicepostkasse med kun nødvendig adgang.</li><li>Indtast oplysningerne, gem og aktivér. BMC Hub ændrer ikke eller sletter mails via denne opsætning.</li></ol></div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Email Templates -->
|
<!-- Email Templates -->
|
||||||
<div class="tab-pane fade" id="email-templates">
|
<div class="tab-pane fade" id="email-templates">
|
||||||
<div class="card border-0 shadow-sm mb-4">
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
@ -5501,6 +5582,105 @@ async function openAIBenchmarkRun(runId) {
|
|||||||
if (!['completed','failed'].includes(run.status)) pollAIBenchmarkRun(run.id);
|
if (!['completed','failed'].includes(run.status)) pollAIBenchmarkRun(run.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emailField(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
function toggleEmailProviderFields() {
|
||||||
|
const graph = emailField('emailProvider').value === 'microsoft365_graph';
|
||||||
|
emailField('emailGraphFields').classList.toggle('d-none', !graph);
|
||||||
|
emailField('emailImapFields').classList.toggle('d-none', graph);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEmailConnectionFeedback(message, type = 'muted') {
|
||||||
|
const root = emailField('emailConnectionFeedback');
|
||||||
|
root.className = `small text-${type}`;
|
||||||
|
root.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEmailConnection() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/settings/email-connection', {credentials: 'include'});
|
||||||
|
if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke hente e-mailopsætningen'));
|
||||||
|
const data = await response.json();
|
||||||
|
emailField('emailProvider').value = data.provider || 'microsoft365_graph';
|
||||||
|
emailField('emailConnectionEnabled').checked = Boolean(data.is_enabled);
|
||||||
|
emailField('emailMailboxAddress').value = data.mailbox_address || '';
|
||||||
|
emailField('emailSenderName').value = data.sender_name || 'BMC Hub';
|
||||||
|
emailField('emailGraphTenantId').value = data.graph_tenant_id || '';
|
||||||
|
emailField('emailGraphClientId').value = data.graph_client_id || '';
|
||||||
|
emailField('emailImapHost').value = data.imap_host || '';
|
||||||
|
emailField('emailImapPort').value = data.imap_port || 993;
|
||||||
|
emailField('emailImapFolder').value = data.imap_folder || 'INBOX';
|
||||||
|
emailField('emailImapSsl').checked = data.imap_use_ssl !== false;
|
||||||
|
emailField('emailSmtpHost').value = data.smtp_host || '';
|
||||||
|
emailField('emailSmtpPort').value = data.smtp_port || 587;
|
||||||
|
emailField('emailSmtpTls').checked = data.smtp_use_tls !== false;
|
||||||
|
emailField('emailConnectionState').className = `badge text-bg-${data.is_enabled ? 'success' : 'secondary'}`;
|
||||||
|
emailField('emailConnectionState').textContent = data.is_enabled ? `Aktiv: ${data.mailbox_address}` : (data.configured ? 'Gemt, ikke aktiv' : 'Miljøopsætning / ikke konfigureret');
|
||||||
|
setEmailConnectionFeedback(data.has_saved_credentials ? 'Adgangsoplysninger er gemt sikkert.' : 'Ingen adgangsoplysninger gemt i Indstillinger.', 'muted');
|
||||||
|
emailField('emailInboundDisabled').classList.toggle('d-none', Boolean(data.inbound_processing_enabled));
|
||||||
|
toggleEmailProviderFields();
|
||||||
|
} catch (error) {
|
||||||
|
setEmailConnectionFeedback(error.message || 'Kunne ikke hente e-mailopsætningen', 'danger');
|
||||||
|
emailField('emailConnectionState').textContent = 'Kunne ikke indlæse';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEmailConnection(button) {
|
||||||
|
const provider = emailField('emailProvider').value;
|
||||||
|
const payload = {
|
||||||
|
provider,
|
||||||
|
is_enabled: emailField('emailConnectionEnabled').checked,
|
||||||
|
mailbox_address: emailField('emailMailboxAddress').value.trim(),
|
||||||
|
sender_name: emailField('emailSenderName').value.trim(),
|
||||||
|
graph_tenant_id: emailField('emailGraphTenantId').value.trim(),
|
||||||
|
graph_client_id: emailField('emailGraphClientId').value.trim(),
|
||||||
|
graph_client_secret: emailField('emailGraphClientSecret').value,
|
||||||
|
imap_host: emailField('emailImapHost').value.trim(),
|
||||||
|
imap_port: Number(emailField('emailImapPort').value),
|
||||||
|
imap_folder: emailField('emailImapFolder').value.trim(),
|
||||||
|
imap_use_ssl: emailField('emailImapSsl').checked,
|
||||||
|
imap_password: emailField('emailImapPassword').value,
|
||||||
|
smtp_host: emailField('emailSmtpHost').value.trim(),
|
||||||
|
smtp_port: Number(emailField('emailSmtpPort').value),
|
||||||
|
smtp_use_tls: emailField('emailSmtpTls').checked,
|
||||||
|
smtp_password: emailField('emailSmtpPassword').value,
|
||||||
|
};
|
||||||
|
button.disabled = true;
|
||||||
|
setEmailConnectionFeedback('Gemmer …', 'muted');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/settings/email-connection', {
|
||||||
|
method: 'PUT', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke gemme e-mailopsætningen'));
|
||||||
|
emailField('emailGraphClientSecret').value = '';
|
||||||
|
emailField('emailImapPassword').value = '';
|
||||||
|
emailField('emailSmtpPassword').value = '';
|
||||||
|
setEmailConnectionFeedback('E-mailopsætningen er gemt. Nye mails bruger den aktive postkasse.', 'success');
|
||||||
|
await loadEmailConnection();
|
||||||
|
} catch (error) {
|
||||||
|
setEmailConnectionFeedback(error.message || 'Kunne ikke gemme e-mailopsætningen', 'danger');
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testEmailConnection(button) {
|
||||||
|
button.disabled = true;
|
||||||
|
setEmailConnectionFeedback('Tester login og læseadgang — der sendes ingen e-mail …', 'muted');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/settings/email-connection/test', {
|
||||||
|
method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: '{}',
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await getErrorMessage(response, 'Forbindelsestesten fejlede'));
|
||||||
|
const result = await response.json();
|
||||||
|
setEmailConnectionFeedback(result.message || 'Forbindelsen virker.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
setEmailConnectionFeedback(error.message || 'Forbindelsestesten fejlede', 'danger');
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tab navigation
|
// Tab navigation
|
||||||
document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
||||||
link.addEventListener('click', (e) => {
|
link.addEventListener('click', (e) => {
|
||||||
@ -5534,6 +5714,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
|||||||
loadTaskTemplates();
|
loadTaskTemplates();
|
||||||
} else if (tab === 'telefoni') {
|
} else if (tab === 'telefoni') {
|
||||||
renderTelefoniSettings();
|
renderTelefoniSettings();
|
||||||
|
} else if (tab === 'email') {
|
||||||
|
loadEmailConnection();
|
||||||
} else if (tab === 'mission') {
|
} else if (tab === 'mission') {
|
||||||
renderMissionSettings();
|
renderMissionSettings();
|
||||||
} else if (tab === 'ai-prompts') {
|
} else if (tab === 'ai-prompts') {
|
||||||
|
|||||||
@ -2004,7 +2004,7 @@ if (bmcOriginalFetch) {
|
|||||||
<script src="/static/js/tag-picker.js?v=2.3"></script>
|
<script src="/static/js/tag-picker.js?v=2.3"></script>
|
||||||
<script src="/static/js/task-template-selector.js?v=1.1"></script>
|
<script src="/static/js/task-template-selector.js?v=1.1"></script>
|
||||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||||
<script src="/static/js/telefoni.js?v=2.5"></script>
|
<script src="/static/js/telefoni.js?v=2.6"></script>
|
||||||
<script src="/static/js/sms.js?v=1.1"></script>
|
<script src="/static/js/sms.js?v=1.1"></script>
|
||||||
<script src="/static/js/bug-report.js?v=1.6"></script>
|
<script src="/static/js/bug-report.js?v=1.6"></script>
|
||||||
{% include "shared/frontend/internal_message.html" %}
|
{% include "shared/frontend/internal_message.html" %}
|
||||||
@ -2325,6 +2325,7 @@ if (bmcOriginalFetch) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function openNewCasePage() {
|
function openNewCasePage() {
|
||||||
|
if (window.location.pathname === '/sag/new' && window.caseCreateUI?.nextType?.()) return;
|
||||||
window.location.href = '/sag/new';
|
window.location.href = '/sag/new';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -194,6 +194,16 @@ def _assert_prepaid_entry_editable(entry: Dict[str, Any]) -> None:
|
|||||||
|
|
||||||
def _settlement_options_for_customer(customer_id: Any) -> Dict[str, Any]:
|
def _settlement_options_for_customer(customer_id: Any) -> Dict[str, Any]:
|
||||||
"""Return valid settlement choices and a deterministic recommendation."""
|
"""Return valid settlement choices and a deterministic recommendation."""
|
||||||
|
if customer_id is None:
|
||||||
|
return {
|
||||||
|
"recommended": {
|
||||||
|
"method": "internal",
|
||||||
|
"reason": "Sagen har ingen kunde og registreres derfor som intern tid",
|
||||||
|
},
|
||||||
|
"prepaid_cards": [],
|
||||||
|
"agreements": [],
|
||||||
|
}
|
||||||
|
|
||||||
customer = execute_query_single(
|
customer = execute_query_single(
|
||||||
"SELECT id, hub_customer_id FROM tmodule_customers WHERE id = %s",
|
"SELECT id, hub_customer_id FROM tmodule_customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,),
|
||||||
@ -234,6 +244,55 @@ def _settlement_options_for_customer(customer_id: Any) -> Dict[str, Any]:
|
|||||||
return {"recommended": recommendation, "prepaid_cards": cards, "agreements": agreements}
|
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:
|
||||||
@ -2410,11 +2469,6 @@ async def start_live_timer_v1(
|
|||||||
raise HTTPException(status_code=400, detail="sag_id is required")
|
raise HTTPException(status_code=400, detail="sag_id is required")
|
||||||
|
|
||||||
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id"))
|
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id"))
|
||||||
if customer_id is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Kunde er ikke linked til tidsmodulet. Kør kundesync/linking for kunden før tidsregistrering.",
|
|
||||||
)
|
|
||||||
|
|
||||||
bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id"))
|
bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id"))
|
||||||
if not bruger_id:
|
if not bruger_id:
|
||||||
@ -2453,7 +2507,7 @@ async def start_live_timer_v1(
|
|||||||
user_name = payload.get("user_name") or default_user_name
|
user_name = payload.get("user_name") or default_user_name
|
||||||
entry_type = payload.get("entry_type") or "manuel"
|
entry_type = payload.get("entry_type") or "manuel"
|
||||||
kilde = payload.get("kilde") or "manuel"
|
kilde = payload.get("kilde") or "manuel"
|
||||||
billable = bool(payload.get("fakturerbar", True))
|
billable = customer_id is not None and bool(payload.get("fakturerbar", True))
|
||||||
round_block_min = int(payload.get("round_block_min") or 30)
|
round_block_min = int(payload.get("round_block_min") or 30)
|
||||||
|
|
||||||
created = execute_query(
|
created = execute_query(
|
||||||
@ -2880,11 +2934,6 @@ async def create_manual_time_v1(
|
|||||||
raise HTTPException(status_code=400, detail="sag_id is required")
|
raise HTTPException(status_code=400, detail="sag_id is required")
|
||||||
|
|
||||||
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id"))
|
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id"))
|
||||||
if customer_id is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Kunde er ikke linked til tidsmodulet. Kør kundesync/linking for kunden før tidsregistrering.",
|
|
||||||
)
|
|
||||||
|
|
||||||
requested_user_ids = payload.get("medarbejder_ids")
|
requested_user_ids = payload.get("medarbejder_ids")
|
||||||
is_bulk_request = requested_user_ids is not None
|
is_bulk_request = requested_user_ids is not None
|
||||||
@ -2921,11 +2970,13 @@ async def create_manual_time_v1(
|
|||||||
raise HTTPException(status_code=400, detail="faktisk_tid_min or original_hours must be > 0")
|
raise HTTPException(status_code=400, detail="faktisk_tid_min or original_hours must be > 0")
|
||||||
|
|
||||||
round_block_min = int(payload.get("round_block_min") or 30)
|
round_block_min = int(payload.get("round_block_min") or 30)
|
||||||
billable = bool(payload.get("fakturerbar", True))
|
billable = customer_id is not None and bool(payload.get("fakturerbar", True))
|
||||||
billable_minutes = payload.get("fakturerbar_tid_min")
|
billable_minutes = payload.get("fakturerbar_tid_min")
|
||||||
if billable_minutes is None:
|
if billable_minutes is None:
|
||||||
billable_minutes = _round_up_minutes(actual_minutes, round_block_min)
|
billable_minutes = _round_up_minutes(actual_minutes, round_block_min)
|
||||||
billable_minutes = int(billable_minutes)
|
billable_minutes = int(billable_minutes)
|
||||||
|
if not billable:
|
||||||
|
billable_minutes = 0
|
||||||
|
|
||||||
worked_date = payload.get("worked_date")
|
worked_date = payload.get("worked_date")
|
||||||
if not worked_date:
|
if not worked_date:
|
||||||
@ -3001,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:
|
||||||
@ -3009,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] = {}
|
||||||
@ -3069,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
|
||||||
@ -3080,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"])
|
||||||
@ -3116,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":
|
||||||
|
|||||||
@ -7,6 +7,8 @@ Denne mappe er det faste sted for idéer og udviklingsplaner, som er aftalt, men
|
|||||||
- [Vareintegration mellem BMC Hub og e-conomic](vareintegration-economic.md) — permanent varekobling, produktsync, oprettelse, prisberegning og sikker dokumenteksport.
|
- [Vareintegration mellem BMC Hub og e-conomic](vareintegration-economic.md) — permanent varekobling, produktsync, oprettelse, prisberegning og sikker dokumenteksport.
|
||||||
- [Kundestemning med AI](kundestemning-ai.md) — AI-analyse af stemning, hast og kunderisiko på indgående mails og sager.
|
- [Kundestemning med AI](kundestemning-ai.md) — AI-analyse af stemning, hast og kunderisiko på indgående mails og sager.
|
||||||
- [Sikkert fakturaudtræk med layoutanalyse og AI](sikker-fakturaudtraek-hybrid-ai.md) — revisionssikker hybridpipeline med felt-evidens, validering og manuel kontrol.
|
- [Sikkert fakturaudtræk med layoutanalyse og AI](sikker-fakturaudtraek-hybrid-ai.md) — revisionssikker hybridpipeline med felt-evidens, validering og manuel kontrol.
|
||||||
|
- [Kuando Busylight og central Presence](kuando-busylight-integration.md) — fælles presence, direkte intern KuandoHUB-styring og senere notifikationsalarmer.
|
||||||
|
- [Sag: topbar-filupload og personlig Buzzwords-visning](sag-filupload-og-buzzwords.md) — upload fra papirclips i topbaren, skjult tomt filmodul og personlig Buzzwords-visning.
|
||||||
|
|
||||||
## Arbejdsgang
|
## Arbejdsgang
|
||||||
|
|
||||||
|
|||||||
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
99
fremtidige planer/kuando-busylight-integration.md
Normal file
99
fremtidige planer/kuando-busylight-integration.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# Kuando Busylight og central Presence i BMC Hub
|
||||||
|
|
||||||
|
**Status:** Planlagt
|
||||||
|
|
||||||
|
## Formål
|
||||||
|
|
||||||
|
Kuando Busylight bliver et output fra BMC Hubs fælles Presence Engine. Hubben er eneste status-master; profilprik, medarbejdervisning, Mission Control og Busylight bruger den samme resolved status og farve.
|
||||||
|
|
||||||
|
Hubben kalder KuandoHUB direkte på brugerens registrerede interne IP. Der bruges ingen BMC Device Agent.
|
||||||
|
|
||||||
|
## Arkitektur og afgrænsning
|
||||||
|
|
||||||
|
```text
|
||||||
|
Hændelse eller Hub-session
|
||||||
|
↓
|
||||||
|
Presence Engine
|
||||||
|
┌──────┼───────────────┐
|
||||||
|
↓ ↓ ↓
|
||||||
|
Profil Mission Control Kuando-klient i Hubben
|
||||||
|
↓
|
||||||
|
http://<intern-ip>:8989/
|
||||||
|
↓
|
||||||
|
KuandoHUB
|
||||||
|
↓
|
||||||
|
USB Busylight
|
||||||
|
```
|
||||||
|
|
||||||
|
- Hubben må kun kontakte IPv4-adresser i `172.16.0.0/12`.
|
||||||
|
- KuandoHUB bruger altid HTTP, port `8989` og den faste `/`-endpoint.
|
||||||
|
- Der understøttes én Busylight/KUandoHUB-konfiguration pr. bruger i første version.
|
||||||
|
- Brugeren indtaster selv sin interne IP i Profil → Busylight. IP-adressen er en begrænset destinationsadresse, ikke en login-mekanisme.
|
||||||
|
- Brugeren kan være `Available`, selv om KuandoHUB ikke kan nås. Enhedsstatus og presence er separate tilstande.
|
||||||
|
|
||||||
|
## Presence Engine
|
||||||
|
|
||||||
|
Det globale, administratorstyrede statuskatalog starter med:
|
||||||
|
|
||||||
|
| Status | Farve |
|
||||||
|
| --- | --- |
|
||||||
|
| Available | Grøn |
|
||||||
|
| Working | Gul |
|
||||||
|
| Busy | Rød |
|
||||||
|
| Do not disturb | Lilla |
|
||||||
|
| Lunch | Cyan |
|
||||||
|
| Away | Orange |
|
||||||
|
| Not at work | Pink |
|
||||||
|
| Offline | Grå |
|
||||||
|
|
||||||
|
- Normalstatus er `Available` ved aktiv Hub-session og `Offline`, når sidste aktive session lukker.
|
||||||
|
- En manuel status varer, indtil brugeren selv ændrer den.
|
||||||
|
- Prioritet opløses centralt: critical → phone → meeting → do-not-disturb → working → available.
|
||||||
|
- Når timer, opkald eller en notifikation ophører, genberegnes den gældende status. Der lagres ikke en løs farve, der blot skal genskabes.
|
||||||
|
- Presence udsendes gennem en dedikeret autentificeret WebSocket med bruger, resolved status, farve, kilde og tidspunkt. Eksisterende WebSocket-kontrakter for telefoni, Mission Control og bundbaren bevares.
|
||||||
|
|
||||||
|
## Profil og enhed
|
||||||
|
|
||||||
|
Profil → Busylight indeholder aktivering, intern IP, online-/fejlstatus, seneste kommando og testknapper for grøn, gul, rød, blå, lilla og sluk.
|
||||||
|
|
||||||
|
Kuando-klienten validerer destinationsadressen, følger ikke redirects og bruger korte connect-/response-timeouts. Kommandoresultater og fejl gemmes. UI må kun angive, at KuandoHUB accepterede kommandoen; fysisk lysrespons bekræftes visuelt.
|
||||||
|
|
||||||
|
## Leveringsrækkefølge
|
||||||
|
|
||||||
|
### Fase 1
|
||||||
|
|
||||||
|
- Statuskatalog og central resolved presence.
|
||||||
|
- Profilprik, Profil → Busylight og direkte KuandoHUB-kald.
|
||||||
|
- Manuelle farvetests og sluk.
|
||||||
|
|
||||||
|
### Fase 2
|
||||||
|
|
||||||
|
- Timer-start/-stop kobles til Working/Available.
|
||||||
|
- Telefoni: ringning giver midlertidig notifikation, besvaret samtale giver Busy, og afslutning genskaber den beregnede status.
|
||||||
|
- Mission Control viser samme medarbejderstatus.
|
||||||
|
|
||||||
|
### Fase 3
|
||||||
|
|
||||||
|
- Separat notification-overlay med blink/pulse, varighed og valgfri lyd.
|
||||||
|
- Tilslut ny/tildelt/hastesag, direkte besked, indgående opkald samt Mission Controls drift-warning og drift-critical.
|
||||||
|
- Critical-alarm kan kvitteres i Hubben og vender derefter tilbage til gældende presence.
|
||||||
|
- Profilen får lydvalg, volumen, ingen lyd under samtale/møde og quiet hours.
|
||||||
|
|
||||||
|
## Data og API'er
|
||||||
|
|
||||||
|
- Nye migrationer opretter statuskatalog, brugerpresence/overlejringer, Busylight-enhedsindstillinger, kommandohistorik og alarmkvitteringer.
|
||||||
|
- Autentificerede API'er dækker læs/ændr egen presence og Busylight-indstilling, hent/test egen enhed, administrér globale statuser og notifikationsregler samt kvittér critical-alarm.
|
||||||
|
- Kun administrator må ændre det globale statuskatalog. Brugeren kan kun læse og ændre sin egen enhed og status.
|
||||||
|
|
||||||
|
## Test og accept
|
||||||
|
|
||||||
|
- Enhedstest for prioritet, manuel overstyring, session-baseret online/offline og genberegning efter timer, opkald og notifikation.
|
||||||
|
- API- og autorisationstest for egne enheder, administratoradgang og afvisning af ugyldige, offentlige eller ikke-`172.16/12` IP-adresser.
|
||||||
|
- HTTP-test mod mock KuandoHUB for farver, sluk, timeout og fejl.
|
||||||
|
- WebSocket-test for realtime profil-/Mission Control-opdatering uden regression i eksisterende telefoni og bundbar.
|
||||||
|
- Manuel accept på internt net: visuel bekræftelse af testknapper, timer og opkald; utilgængelig KuandoHUB må kun gøre enheden fejlende.
|
||||||
|
|
||||||
|
## Forudsætninger
|
||||||
|
|
||||||
|
- Hub-serveren har netværksrute til brugernes `172.16.x.x`-adresser, og KuandoHUB lytter på port `8989`.
|
||||||
|
- Implementeringen ændrer ikke eksisterende API-adfærd uden for de nye presence- og Busylight-grænseflader.
|
||||||
341
fremtidige planer/sag-filupload-og-buzzwords.md
Normal file
341
fremtidige planer/sag-filupload-og-buzzwords.md
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Sag: topbar-filupload og personlig Buzzwords-visning
|
||||||
|
|
||||||
|
**Status:** Under udvikling
|
||||||
|
|
||||||
|
## Formål
|
||||||
|
|
||||||
|
Gør filupload direkte tilgængelig fra sagens topbar, uden at en tom filsektion fylder i sagsvisningen. Giv samtidig hver bruger mulighed for at skjule Buzzwords-modulet i sin egen sagsvisning.
|
||||||
|
|
||||||
|
## Løsning
|
||||||
|
|
||||||
|
- Tilføj et papirclips-ikon i sagens topbar. Ikonet åbner den eksisterende sagsfil-upload gennem en skjult filvælger og uploader filen til den aktuelle sag.
|
||||||
|
- Bevar eksisterende fil-API, tilladelser, filvalidering og lagring; topbar-handlingen er kun en ekstra indgang til samme uploadfunktion.
|
||||||
|
- Efter en vellykket upload opdateres filantal og filmodul uden fuld sidegenindlæsning, og brugeren får tydelig succes- eller fejlbesked.
|
||||||
|
- Vis filmodulet, når sagen har mindst én fil. Skjul det automatisk, når sagen ikke indeholder filer; topbar-papirclipsen er stadig tilgængelig, så den første fil kan uploades.
|
||||||
|
- Tilføj en personlig modulpræference for Buzzwords. Brugeren kan skjule eller vise modulet, og valget gælder kun brugerens egen sagsvisning.
|
||||||
|
- Et tomt Buzzwords-modul skal fortsat være skjult. En gemt brugerpræference må ikke skabe et tomt modul.
|
||||||
|
|
||||||
|
## Data og kompatibilitet
|
||||||
|
|
||||||
|
- Genbrug eksisterende `sag_files` og den nuværende modulpræference-mekanisme; opret kun en migration, hvis Buzzwords mangler en vedvarende, bruger-specifik nøgle i den eksisterende præferencetabel.
|
||||||
|
- Eksisterende filupload og sags-API'er ændrer ikke adfærd. Den nye topbar-handling bruger samme autorisationskontrol som det eksisterende filmodul.
|
||||||
|
|
||||||
|
## Test og accept
|
||||||
|
|
||||||
|
- Upload via papirclips opretter samme filrecord og giver samme resultat som eksisterende upload.
|
||||||
|
- Uautoriseret eller fejlet upload vises som fejl og ændrer ikke filantal eller modulvisning.
|
||||||
|
- Sag uden filer viser ikke filmodulet, men kan modtage sin første fil via topbaren; modulet vises derefter.
|
||||||
|
- Buzzwords kan skjules og vises for én bruger uden at påvirke andre brugere, og er skjult for alle når indholdet er tomt.
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Sikkert fakturaudtræk med layoutanalyse og AI
|
$# Sikkert fakturaudtræk med layoutanalyse og AI
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
|||||||
30
migrations/1035_email_connection_configuration.sql
Normal file
30
migrations/1035_email_connection_configuration.sql
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
-- Central, encrypted configuration for the mailbox BMC Hub reads and sends from.
|
||||||
|
-- Secrets are stored as a Fernet-encrypted JSON blob and are never returned by the API.
|
||||||
|
CREATE TABLE IF NOT EXISTS email_connection_configurations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
provider VARCHAR(30) NOT NULL CHECK (provider IN ('microsoft365_graph', 'imap_smtp')),
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
mailbox_address VARCHAR(320) NOT NULL,
|
||||||
|
sender_name VARCHAR(255) NOT NULL DEFAULT 'BMC Hub',
|
||||||
|
imap_host VARCHAR(255),
|
||||||
|
imap_port INTEGER NOT NULL DEFAULT 993,
|
||||||
|
imap_folder VARCHAR(255) NOT NULL DEFAULT 'INBOX',
|
||||||
|
imap_use_ssl BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
smtp_host VARCHAR(255),
|
||||||
|
smtp_port INTEGER NOT NULL DEFAULT 587,
|
||||||
|
smtp_use_tls BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
graph_tenant_id VARCHAR(255),
|
||||||
|
graph_client_id VARCHAR(255),
|
||||||
|
secrets_encrypted TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by_user_id INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_email_connection_singleton
|
||||||
|
ON email_connection_configurations ((1));
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS email_connection_configurations_updated_at_trigger ON email_connection_configurations;
|
||||||
|
CREATE TRIGGER email_connection_configurations_updated_at_trigger
|
||||||
|
BEFORE UPDATE ON email_connection_configurations
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_settings_updated_at();
|
||||||
12
migrations/1036_sag_individual_assignments.sql
Normal file
12
migrations/1036_sag_individual_assignments.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- Explicit employee assignments for a case. A group is only a picker/context;
|
||||||
|
-- each selected employee has a separate row.
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_case_assignees (
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
assigned_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
PRIMARY KEY (sag_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_case_assignees_user
|
||||||
|
ON sag_case_assignees(user_id, sag_id);
|
||||||
13
migrations/1037_case_type_details.sql
Normal file
13
migrations/1037_case_type_details.sql
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
-- Stores the structured fields shown by the dynamic new-case templates.
|
||||||
|
-- Kept separately so legacy case payloads and sag_sager remain unchanged.
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_case_details (
|
||||||
|
sag_id INTEGER PRIMARY KEY REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
updated_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_case_details_details
|
||||||
|
ON sag_case_details USING GIN (details);
|
||||||
7
migrations/1038_sync_case_type_with_template.sql
Normal file
7
migrations/1038_sync_case_type_with_template.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- Keep the legacy type column aligned with template_key.
|
||||||
|
-- Some V4 components still consume type while newer flows use template_key.
|
||||||
|
UPDATE sag_sager
|
||||||
|
SET type = template_key
|
||||||
|
WHERE template_key IS NOT NULL
|
||||||
|
AND BTRIM(template_key) <> ''
|
||||||
|
AND type IS DISTINCT FROM template_key;
|
||||||
17
migrations/1039_set_supported_case_types.sql
Normal file
17
migrations/1039_set_supported_case_types.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
-- Canonical case types shown when creating and editing V4 cases.
|
||||||
|
-- Existing cases keep their stored type values for backwards compatibility.
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES (
|
||||||
|
'case_types',
|
||||||
|
'["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"]',
|
||||||
|
'system',
|
||||||
|
'Sags-typer',
|
||||||
|
'json',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
value_type = EXCLUDED.value_type,
|
||||||
|
is_public = EXCLUDED.is_public,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
6
migrations/1040_tmodule_times_optional_customer.sql
Normal file
6
migrations/1040_tmodule_times_optional_customer.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
-- Internal work, including Post-it cases, may be registered before a customer
|
||||||
|
-- is attached. Customer-specific settlement is selected only after a customer
|
||||||
|
-- exists, so the time row itself must allow a null customer reference.
|
||||||
|
ALTER TABLE tmodule_times
|
||||||
|
ALTER COLUMN customer_id DROP NOT NULL;
|
||||||
|
|
||||||
22
migrations/1041_seed_internal_case_customer.sql
Normal file
22
migrations/1041_seed_internal_case_customer.sql
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
-- Internal tickets have a fixed, canonical customer identity.
|
||||||
|
-- Never overwrite another customer if an installation already uses ID 8.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
existing_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
SELECT name INTO existing_name FROM customers WHERE id = 8;
|
||||||
|
|
||||||
|
IF existing_name IS NULL THEN
|
||||||
|
INSERT INTO customers (id, name, email, email_domain, country, is_active)
|
||||||
|
VALUES (8, 'BMC Networks', 'info@bmcnetworks.dk', 'bmcnetworks.dk', 'DK', TRUE);
|
||||||
|
ELSIF LOWER(TRIM(existing_name)) <> LOWER('BMC Networks') THEN
|
||||||
|
RAISE EXCEPTION 'Customer ID 8 is already used by %, expected BMC Networks', existing_name;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
SELECT setval(
|
||||||
|
pg_get_serial_sequence('customers', 'id'),
|
||||||
|
GREATEST((SELECT COALESCE(MAX(id), 1) FROM customers), 1),
|
||||||
|
TRUE
|
||||||
|
);
|
||||||
|
|
||||||
30
migrations/1042_user_sag_module_prefs.sql
Normal file
30
migrations/1042_user_sag_module_prefs.sql
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
-- Migration 1042: Per-user visibility preferences for case modules.
|
||||||
|
-- Existing sag_module_prefs remain case-wide for backwards compatibility.
|
||||||
|
-- New per-user preferences are initially used for the Buzzwords module only.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_sag_module_prefs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
module_key VARCHAR(50) NOT NULL,
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, sag_id, module_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_sag_module_prefs_user_sag
|
||||||
|
ON user_sag_module_prefs(user_id, sag_id);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_user_sag_module_prefs_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_user_sag_module_prefs_updated_at ON user_sag_module_prefs;
|
||||||
|
CREATE TRIGGER update_user_sag_module_prefs_updated_at
|
||||||
|
BEFORE UPDATE ON user_sag_module_prefs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_user_sag_module_prefs_updated_at();
|
||||||
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);
|
||||||
@ -3,7 +3,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
const state = {tags: [], hardware: [], tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false};
|
const state = {tags: [], hardware: [], typeDetails: {}, openModules: {}, tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false};
|
||||||
let saveTimer, duplicateTimer, duplicateToken = 0, workloadToken = 0, contactCasesToken = 0, tagSuggestionTimer, templates = [];
|
let saveTimer, duplicateTimer, duplicateToken = 0, workloadToken = 0, contactCasesToken = 0, tagSuggestionTimer, templates = [];
|
||||||
async function api(url, options) {
|
async function api(url, options) {
|
||||||
const response = await fetch(url, {credentials: 'include', ...options});
|
const response = await fetch(url, {credentials: 'include', ...options});
|
||||||
@ -38,7 +38,7 @@
|
|||||||
}
|
}
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies,
|
return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies,
|
||||||
tags: state.tags, hardware: state.hardware, orders: collectOrderItems(), prefill: telefoniPrefill,
|
tags: state.tags, hardware: state.hardware, typeDetails: state.typeDetails, openModules: state.openModules, orders: collectOrderItems(), prefill: telefoniPrefill,
|
||||||
customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value,
|
customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value,
|
||||||
panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))};
|
panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))};
|
||||||
}
|
}
|
||||||
@ -53,6 +53,7 @@
|
|||||||
$('cc-save').textContent = 'Gemmer kladde…'; clearTimeout(saveTimer); saveTimer = setTimeout(save, 500);
|
$('cc-save').textContent = 'Gemmer kladde…'; clearTimeout(saveTimer); saveTimer = setTimeout(save, 500);
|
||||||
}
|
}
|
||||||
function renderTags() {
|
function renderTags() {
|
||||||
|
if (!$('cc-tag-chips')) return;
|
||||||
$('cc-tag-chips').innerHTML = state.tags.map(t => `<span class="badge me-1 mb-1" style="background-color:#64748b" data-tag-id="${Number(t.id)}">${/^bi-[a-z0-9-]+$/.test(t.icon || '') ? `<i class="bi ${esc(t.icon)}"></i> ` : ''}${esc(t.name)} <button type="button" class="btn-close btn-close-white" aria-label="Fjern ${esc(t.name)}"></button></span>`).join('');
|
$('cc-tag-chips').innerHTML = state.tags.map(t => `<span class="badge me-1 mb-1" style="background-color:#64748b" data-tag-id="${Number(t.id)}">${/^bi-[a-z0-9-]+$/.test(t.icon || '') ? `<i class="bi ${esc(t.icon)}"></i> ` : ''}${esc(t.name)} <button type="button" class="btn-close btn-close-white" aria-label="Fjern ${esc(t.name)}"></button></span>`).join('');
|
||||||
state.tags.forEach(t => { const chip = $('cc-tag-chips').querySelector(`[data-tag-id="${Number(t.id)}"]`); if (/^#[0-9a-f]{6}$/i.test(t.color)) chip.style.backgroundColor = t.color; });
|
state.tags.forEach(t => { const chip = $('cc-tag-chips').querySelector(`[data-tag-id="${Number(t.id)}"]`); if (/^#[0-9a-f]{6}$/i.test(t.color)) chip.style.backgroundColor = t.color; });
|
||||||
summaries();
|
summaries();
|
||||||
@ -83,6 +84,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function renderSelectedHardware() {
|
function renderSelectedHardware() {
|
||||||
|
if (!$('cc-hardware-chips')) return;
|
||||||
$('cc-hardware-chips').innerHTML = state.hardware.map(h => `<span class="badge bg-secondary me-1">${esc(h.name)} <button type="button" class="btn-close btn-close-white" data-remove-hardware="${Number(h.id)}" aria-label="Fjern hardware"></button></span>`).join('');
|
$('cc-hardware-chips').innerHTML = state.hardware.map(h => `<span class="badge bg-secondary me-1">${esc(h.name)} <button type="button" class="btn-close btn-close-white" data-remove-hardware="${Number(h.id)}" aria-label="Fjern hardware"></button></span>`).join('');
|
||||||
document.querySelectorAll('[data-hardware-choice]').forEach(el => { el.checked = state.hardware.some(h => h.id === Number(el.dataset.hardwareChoice)); });
|
document.querySelectorAll('[data-hardware-choice]').forEach(el => { el.checked = state.hardware.some(h => h.id === Number(el.dataset.hardwareChoice)); });
|
||||||
summaries();
|
summaries();
|
||||||
@ -128,25 +130,143 @@
|
|||||||
$('cc-workload').innerHTML = `<details><summary>${data.total} åbne sager · ${esc($('ansvarlig_bruger_id').selectedOptions[0].text)}</summary>${data.items.map(r => `<div class="cc-work-item"><a target="_blank" rel="noopener" href="/sag/${r.id}/v3">SAG-${r.id} · ${esc(r.titel)}</a><small>${esc(r.customer_name || '')} · ${esc(r.status)} <span class="${r.deadline && new Date(r.deadline) < new Date() ? 'text-danger' : ''}">${r.deadline ? esc(new Date(r.deadline).toLocaleString('da-DK')) : ''}</span></small></div>`).join('') || '<p class="text-success">Ingen åbne sager.</p>'}${data.total > 10 ? `<a href="/sag?ansvarlig_bruger_id=${id}" target="_blank" rel="noopener">Se alle ${data.total} sager</a>` : ''}</details>`;
|
$('cc-workload').innerHTML = `<details><summary>${data.total} åbne sager · ${esc($('ansvarlig_bruger_id').selectedOptions[0].text)}</summary>${data.items.map(r => `<div class="cc-work-item"><a target="_blank" rel="noopener" href="/sag/${r.id}/v3">SAG-${r.id} · ${esc(r.titel)}</a><small>${esc(r.customer_name || '')} · ${esc(r.status)} <span class="${r.deadline && new Date(r.deadline) < new Date() ? 'text-danger' : ''}">${r.deadline ? esc(new Date(r.deadline).toLocaleString('da-DK')) : ''}</span></small></div>`).join('') || '<p class="text-success">Ingen åbne sager.</p>'}${data.total > 10 ? `<a href="/sag?ansvarlig_bruger_id=${id}" target="_blank" rel="noopener">Se alle ${data.total} sager</a>` : ''}</details>`;
|
||||||
} catch { if (token === workloadToken) $('cc-workload').textContent = 'Kunne ikke hente åbne sager.'; }
|
} catch { if (token === workloadToken) $('cc-workload').textContent = 'Kunne ikke hente åbne sager.'; }
|
||||||
}
|
}
|
||||||
function typeChanged(type) {
|
function typeChanged(type, showTypeFields = false) {
|
||||||
|
if (type === 'intern_ticket') {
|
||||||
|
selectedCustomer = {id: 8, name: 'BMC Networks'};
|
||||||
|
$('customer_id').value = '8';
|
||||||
|
$('customerSearch').value = 'BMC Networks';
|
||||||
|
const creatorId = Number(window.caseCreateCurrentUserId || 0);
|
||||||
|
if (creatorId && Array.from($('ansvarlig_bruger_id').options).some(option => Number(option.value) === creatorId)) {
|
||||||
|
$('ansvarlig_bruger_id').value = String(creatorId);
|
||||||
|
}
|
||||||
|
window.renderSelections?.();
|
||||||
|
}
|
||||||
|
captureTypeDetails();
|
||||||
|
renderTypeDetails(type);
|
||||||
|
$('typeDetailsSection')?.classList.toggle('d-none', !showTypeFields || !(typeFieldSets[type] || []).length);
|
||||||
|
$('createForm').closest('.card-custom')?.classList.toggle('cc-postit-mode', type === 'postit');
|
||||||
|
state.panels['type-details']?.classList.toggle('cc-postit-panel', type === 'postit');
|
||||||
document.querySelectorAll('[data-case-type]').forEach(b => { const active = b.dataset.caseType === type; b.classList.toggle('active', active); b.setAttribute('aria-pressed', String(active)); });
|
document.querySelectorAll('[data-case-type]').forEach(b => { const active = b.dataset.caseType === type; b.classList.toggle('active', active); b.setAttribute('aria-pressed', String(active)); });
|
||||||
const key = {ticket:'hardware', pipeline:'pipeline', ordre:'order'}[type];
|
const visiblePanels = {
|
||||||
if (key && state.panels[key] && !state.restoring) state.panels[key].open = true;
|
ticket: ['relations', 'type-details', 'hardware'],
|
||||||
|
salg: ['relations', 'type-details', 'pipeline', 'order'],
|
||||||
|
bogholderi: ['relations', 'type-details', 'order'],
|
||||||
|
admin: ['type-details'],
|
||||||
|
projekt: ['relations', 'type-details'],
|
||||||
|
postit: ['type-details'],
|
||||||
|
intern_ticket: ['type-details'],
|
||||||
|
bug_report: ['relations', 'type-details', 'hardware'],
|
||||||
|
service: ['relations', 'hardware'],
|
||||||
|
pipeline: ['relations', 'pipeline'],
|
||||||
|
ordre: ['relations', 'order'],
|
||||||
|
opgave: ['relations'],
|
||||||
|
abonnement: ['relations'],
|
||||||
|
}[type] || ['relations'];
|
||||||
|
const availableModules = visiblePanels.filter(key => key !== 'relations');
|
||||||
|
const moduleAction = $('typeModuleAction');
|
||||||
|
if (showTypeFields) state.openModules[type] = [...availableModules];
|
||||||
|
const openModules = state.openModules[type] || [];
|
||||||
|
if (moduleAction) {
|
||||||
|
const typeDetailsLabels = {
|
||||||
|
ticket: 'Ticket-oplysninger', salg: 'Salgsoplysninger', bogholderi: 'Bogholderioplysninger',
|
||||||
|
admin: 'Adminoplysninger', projekt: 'Projektoplysninger', postit: 'Notat og påmindelse',
|
||||||
|
intern_ticket: 'Intern opgave', bug_report: 'Fejlrapport'
|
||||||
|
};
|
||||||
|
const moduleLabels = {'type-details': typeDetailsLabels[type] || 'Oplysninger til sagstypen', hardware:'Hardware', pipeline:'Pipeline', order:'Indkøb og salg'};
|
||||||
|
const moduleIcons = {'type-details':'bi-card-text', hardware:'bi-pc-display', pipeline:'bi-graph-up-arrow', order:'bi-bag'};
|
||||||
|
moduleAction.classList.toggle('d-none', !availableModules.length);
|
||||||
|
moduleAction.innerHTML = availableModules.map(key => `<button type="button" class="btn ${openModules.includes(key) ? 'btn-primary' : 'btn-outline-primary'}" data-case-module="${esc(key)}"><i class="bi ${moduleIcons[key] || 'bi-plus-square'} me-1"></i>${esc(moduleLabels[key] || key)}</button>`).join('');
|
||||||
|
moduleAction.querySelectorAll('[data-case-module]').forEach(button => {
|
||||||
|
button.onclick = () => {
|
||||||
|
const moduleKey = button.dataset.caseModule;
|
||||||
|
state.openModules[type] = [...new Set([...(state.openModules[type] || []), moduleKey])];
|
||||||
|
typeChanged(type, false);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
['relations', 'type-details', 'hardware', 'pipeline', 'order'].forEach(key => state.panels[key]?.classList.toggle('d-none', !openModules.includes(key) || !visiblePanels.includes(key)));
|
||||||
|
const sectionIds = {hardware: 'hardwareSection', pipeline: 'pipelineSection', order: 'orderSection'};
|
||||||
|
$('typeDetailsSection')?.classList.toggle('d-none', !openModules.includes('type-details') || !(typeFieldSets[type] || []).length);
|
||||||
|
Object.entries(sectionIds).forEach(([key, id]) => $(id)?.classList.toggle('d-none', !openModules.includes(key) || !visiblePanels.includes(key)));
|
||||||
|
document.querySelectorAll('.case-type-extra').forEach(el => el.classList.toggle('d-none', !showTypeFields));
|
||||||
|
$('cc-template')?.closest('.cc-template-toolbar')?.classList.toggle('d-none', !showTypeFields);
|
||||||
|
if (showTypeFields && !state.restoring) visiblePanels.forEach(panelKey => { if (state.panels[panelKey]) state.panels[panelKey].open = true; });
|
||||||
if ($('cc-more')) $('cc-more').open = Boolean($('cc-more').querySelector(`[data-case-type="${CSS.escape(type)}"]`));
|
if ($('cc-more')) $('cc-more').open = Boolean($('cc-more').querySelector(`[data-case-type="${CSS.escape(type)}"]`));
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
}
|
}
|
||||||
|
const typeFieldSets = {
|
||||||
|
ticket: [
|
||||||
|
['anydesk_id', 'AnyDesk-ID', 'text', '123-456-789'],
|
||||||
|
['hardware_information', 'Hardwareinformation', 'textarea', 'Enhed, model, serienummer eller fejl'],
|
||||||
|
['solution', 'Løsning', 'textarea', 'Den aftalte eller udførte løsning'],
|
||||||
|
],
|
||||||
|
salg: [
|
||||||
|
['customer_need', 'Kundebehov', 'textarea', 'Hvad ønsker kunden?'],
|
||||||
|
['expected_close_date', 'Forventet lukkedato', 'date', ''],
|
||||||
|
],
|
||||||
|
bogholderi: [
|
||||||
|
['voucher_reference', 'Bilags- eller fakturanummer', 'text', 'F.eks. F-12345'],
|
||||||
|
['supplier', 'Leverandør', 'text', 'Leverandørnavn'],
|
||||||
|
['due_date', 'Forfaldsdato', 'date', ''],
|
||||||
|
],
|
||||||
|
admin: [
|
||||||
|
['area', 'Område eller system', 'text', 'F.eks. brugeradministration'],
|
||||||
|
['requested_action', 'Ønsket handling', 'textarea', 'Hvad skal ændres eller undersøges?'],
|
||||||
|
],
|
||||||
|
projekt: [
|
||||||
|
['project_goal', 'Projektmål', 'textarea', 'Hvad skal projektet levere?'],
|
||||||
|
['start_date', 'Startdato', 'date', ''],
|
||||||
|
['end_date', 'Slutdato', 'date', ''],
|
||||||
|
],
|
||||||
|
postit: [
|
||||||
|
['note', 'Notat', 'textarea', 'Kort huskeseddel'],
|
||||||
|
['remind_date', 'Påmindelsesdato', 'date', ''],
|
||||||
|
],
|
||||||
|
intern_ticket: [
|
||||||
|
['system', 'System', 'text', 'Berørt system eller modul'],
|
||||||
|
['purpose', 'Formål', 'textarea', 'Hvad skal den interne opgave føre til?'],
|
||||||
|
['next_step', 'Næste skridt', 'textarea', 'Hvad skal der ske nu?'],
|
||||||
|
],
|
||||||
|
bug_report: [
|
||||||
|
['affected_area', 'Berørt område', 'text', 'System, side eller modul'],
|
||||||
|
['steps_to_reproduce', 'Trin til at genskabe', 'textarea', 'Beskriv trin for trin hvordan fejlen opstår'],
|
||||||
|
['expected_result', 'Forventet resultat', 'textarea', 'Hvad skulle der være sket?'],
|
||||||
|
['actual_result', 'Faktisk resultat', 'textarea', 'Hvad skete der i stedet?'],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
function captureTypeDetails() {
|
||||||
|
const currentType = $('type')?.value;
|
||||||
|
if (!currentType) return;
|
||||||
|
const values = {};
|
||||||
|
document.querySelectorAll('[data-case-detail]').forEach(field => { values[field.dataset.caseDetail] = field.value; });
|
||||||
|
if (Object.keys(values).length) state.typeDetails[currentType] = values;
|
||||||
|
}
|
||||||
|
function renderTypeDetails(type) {
|
||||||
|
const section = $('typeDetailsSection'), box = $('typeDetailsFields');
|
||||||
|
if (!section || !box) return;
|
||||||
|
const fields = typeFieldSets[type] || [];
|
||||||
|
const values = state.typeDetails[type] || {};
|
||||||
|
box.innerHTML = fields.map(([key, label, kind, placeholder]) => `<div class="mb-3"><label class="form-label" for="case-detail-${esc(key)}">${esc(label)}</label>${kind === 'textarea' ? `<textarea class="form-control" rows="3" id="case-detail-${esc(key)}" data-case-detail="${esc(key)}" placeholder="${esc(placeholder)}">${esc(values[key] || '')}</textarea>` : `<input class="form-control" type="${esc(kind)}" id="case-detail-${esc(key)}" data-case-detail="${esc(key)}" value="${esc(values[key] || '')}" placeholder="${esc(placeholder)}">`}</div>`).join('');
|
||||||
|
section.classList.toggle('d-none', !fields.length);
|
||||||
|
}
|
||||||
function buildTypes() {
|
function buildTypes() {
|
||||||
const options = Array.from($('type').options), current = $('type').value;
|
const options = Array.from($('type').options), current = $('type').value;
|
||||||
const keys = [...new Set([current, 'ticket','pipeline','ordre','opgave', ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k));
|
const keys = [...new Set([current, ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k));
|
||||||
const hints = {ticket:'Support og hjælp',pipeline:'Muligheder og tilbud',ordre:'Indkøb og salg',opgave:'Noget der skal løses',projekt:'Større forløb',service:'Service og vedligehold',abonnement:'Løbende aftaler'};
|
const hints = {ticket:'Support og hjælp',salg:'Tilbud og salg',bogholderi:'Bilag og økonomi',admin:'Intern administration',projekt:'Større forløb',postit:'Huskeseddel',intern_ticket:'Intern opgave',bug_report:'Rapportér en fejl'};
|
||||||
const button = key => `<button type="button" class="btn btn-outline-primary" data-case-type="${esc(key)}" aria-pressed="false"><span class="cc-type-name">${esc(options.find(o => o.value === key).text)}</span><small class="cc-type-hint">${esc(hints[key] || 'Opret sag')}</small></button>`;
|
const button = key => `<button type="button" class="btn btn-outline-primary" data-case-type="${esc(key)}" aria-pressed="false"><span class="cc-type-name">${esc(options.find(o => o.value === key).text)}</span><small class="cc-type-hint">${esc(hints[key] || 'Opret sag')}</small></button>`;
|
||||||
const messageButton = $('caseMessageTypeButton');
|
|
||||||
$('type').classList.add('d-none'); $('type').removeAttribute('required');
|
$('type').classList.add('d-none'); $('type').removeAttribute('required');
|
||||||
const div = document.createElement('div'); div.id = 'cc-types'; div.className = 'cc-types'; div.setAttribute('role', 'group'); div.setAttribute('aria-label', 'Sagstype');
|
const div = document.createElement('div'); div.id = 'cc-types'; div.className = 'cc-types'; div.setAttribute('role', 'group'); div.setAttribute('aria-label', 'Sagstype');
|
||||||
div.innerHTML = keys.slice(0,4).map(button).join('') + (keys.length > 4 ? `<details id="cc-more"><summary>Flere</summary><div class="cc-types">${keys.slice(4).map(button).join('')}</div></details>` : '');
|
div.innerHTML = keys.map(button).join('');
|
||||||
if (messageButton) { messageButton.classList.remove('cc-message-fallback'); messageButton.dataset.caseMessage = 'true'; div.insertBefore(messageButton, div.querySelector('#cc-more')); }
|
|
||||||
$('type').after(div);
|
$('type').after(div);
|
||||||
div.addEventListener('click', e => { const message = e.target.closest('[data-case-message]'); if (message) { window.caseCreateUI?.openMessage(); return; } const b = e.target.closest('[data-case-type]'); if (b) { $('type').value = b.dataset.caseType; $('type').dispatchEvent(new Event('change', {bubbles:true})); } });
|
div.addEventListener('click', e => { const b = e.target.closest('[data-case-type]'); if (b) { $('type').value = b.dataset.caseType; $('type').dispatchEvent(new Event('change', {bubbles:true})); } });
|
||||||
|
}
|
||||||
|
function selectNextType() {
|
||||||
|
const buttons = Array.from(document.querySelectorAll('#cc-types > button[data-case-type]'));
|
||||||
|
if (!buttons.length) return false;
|
||||||
|
const currentType = $('type')?.value;
|
||||||
|
const currentIndex = buttons.findIndex(button => button.dataset.caseType === currentType);
|
||||||
|
buttons[(currentIndex + 1 + buttons.length) % buttons.length].click();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
async function applyTemplate(id) {
|
async function applyTemplate(id) {
|
||||||
const template = templates.find(t => t.id === Number(id)); if (!template) return;
|
const template = templates.find(t => t.id === Number(id)); if (!template) return;
|
||||||
@ -156,12 +276,12 @@
|
|||||||
Object.entries(fields).forEach(([k,v]) => { const el = $(k); if (el && (el.tagName !== 'SELECT' || Array.from(el.options).some(o => o.value === String(v)))) el.value = v; });
|
Object.entries(fields).forEach(([k,v]) => { const el = $(k); if (el && (el.tagName !== 'SELECT' || Array.from(el.options).some(o => o.value === String(v)))) el.value = v; });
|
||||||
const tags = await api('/api/v1/tags?is_active=true');
|
const tags = await api('/api/v1/tags?is_active=true');
|
||||||
for (const id of values.tag_ids || []) { const tag = tags.find(t => t.id === id); if (tag) await addTag(tag); }
|
for (const id of values.tag_ids || []) { const tag = tags.find(t => t.id === id); if (tag) await addTag(tag); }
|
||||||
updateCaseTypeSections(); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; scheduleSave(); scheduleDuplicates();
|
window.updateCaseTypeSections?.(true); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; scheduleSave(); scheduleDuplicates();
|
||||||
}
|
}
|
||||||
async function restore(draft) {
|
async function restore(draft) {
|
||||||
state.restoring = true;
|
state.restoring = true;
|
||||||
try {
|
try {
|
||||||
selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {};
|
selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {}; state.typeDetails = draft.typeDetails || {}; state.openModules = draft.openModules || {};
|
||||||
telefoniPrefill = draft.prefill || telefoniPrefill;
|
telefoniPrefill = draft.prefill || telefoniPrefill;
|
||||||
Object.entries(draft.fields || {}).forEach(([id,value]) => { const el = $(id); if (el && !id.startsWith('cc-')) { if (el.type === 'checkbox') el.checked = value; else el.value = value; } });
|
Object.entries(draft.fields || {}).forEach(([id,value]) => { const el = $(id); if (el && !id.startsWith('cc-')) { if (el.type === 'checkbox') el.checked = value; else el.value = value; } });
|
||||||
state.tags = draft.tags || []; state.hardware = draft.hardware || [];
|
state.tags = draft.tags || []; state.hardware = draft.hardware || [];
|
||||||
@ -171,10 +291,10 @@
|
|||||||
await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null);
|
await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null);
|
||||||
if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id);
|
if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id);
|
||||||
$('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || '';
|
$('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || '';
|
||||||
updateCaseTypeSections(); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; });
|
window.updateCaseTypeSections?.(false); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; });
|
||||||
$('charCount').textContent = `${$('beskrivelse').value.length} tegn`;
|
$('charCount').textContent = `${$('beskrivelse').value.length} tegn`;
|
||||||
} finally { state.restoring = false; }
|
} finally { state.restoring = false; }
|
||||||
state.pending = false; $('cc-draft').remove(); scheduleSave(); scheduleDuplicates(); workload();
|
state.pending = false; $('cc-draft').remove(); scheduleSave();
|
||||||
}
|
}
|
||||||
function validate() {
|
function validate() {
|
||||||
document.querySelectorAll('.cc-error').forEach(el => el.remove());
|
document.querySelectorAll('.cc-error').forEach(el => el.remove());
|
||||||
@ -182,7 +302,11 @@
|
|||||||
const errors = [];
|
const errors = [];
|
||||||
const add = (el, text) => { errors.push(text); el.classList.add('is-invalid'); el.insertAdjacentHTML('afterend', `<div class="text-danger small cc-error">${esc(text)}</div>`); const detail = el.closest('details'); if (detail) { detail.open = true; if (!detail.querySelector('summary .cc-error')) detail.querySelector('summary').insertAdjacentHTML('beforeend','<span class="text-danger small cc-error"> · Kontrollér felter</span>'); } };
|
const add = (el, text) => { errors.push(text); el.classList.add('is-invalid'); el.insertAdjacentHTML('afterend', `<div class="text-danger small cc-error">${esc(text)}</div>`); const detail = el.closest('details'); if (detail) { detail.open = true; if (!detail.querySelector('summary .cc-error')) detail.querySelector('summary').insertAdjacentHTML('beforeend','<span class="text-danger small cc-error"> · Kontrollér felter</span>'); } };
|
||||||
if (!$('titel').value.trim()) add($('titel'), 'Titel er påkrævet');
|
if (!$('titel').value.trim()) add($('titel'), 'Titel er påkrævet');
|
||||||
if (!selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Vælg et firma');
|
if (!$('beskrivelse').value.trim()) add($('beskrivelse'), 'Beskrivelse er påkrævet');
|
||||||
|
const type = $('type').value;
|
||||||
|
const needsCompanyAndContact = ['ticket', 'bogholderi', 'admin'].includes(type);
|
||||||
|
if (needsCompanyAndContact && !selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Virksomhed er påkrævet for denne sagstype');
|
||||||
|
if (needsCompanyAndContact && Object.keys(selectedContacts).length === 0 && $('contactSearch').value.trim().length < 2) add($('contactSearch'), 'Kontakt er påkrævet for denne sagstype');
|
||||||
$('createForm').querySelectorAll('input[type=number]').forEach(el => { if (!el.checkValidity()) add(el, 'Kontrollér tallets interval og format'); });
|
$('createForm').querySelectorAll('input[type=number]').forEach(el => { if (!el.checkValidity()) add(el, 'Kontrollér tallets interval og format'); });
|
||||||
document.querySelectorAll('#orderLines .order-line').forEach(row => { for (const cls of ['description','amount']) { const el = row.querySelector(`.order-${cls}`); if (!el.value.trim()) add(el, cls === 'description' ? 'Ordrelinjen mangler beskrivelse' : 'Ordrelinjen mangler beløb'); } });
|
document.querySelectorAll('#orderLines .order-line').forEach(row => { for (const cls of ['description','amount']) { const el = row.querySelector(`.order-${cls}`); if (!el.value.trim()) add(el, cls === 'description' ? 'Ordrelinjen mangler beskrivelse' : 'Ordrelinjen mangler beløb'); } });
|
||||||
if (errors.length) { $('error').classList.remove('d-none'); $('error-text').textContent = [...new Set(errors)].join(' · '); document.querySelector('.is-invalid')?.focus(); return false; }
|
if (errors.length) { $('error').classList.remove('d-none'); $('error-text').textContent = [...new Set(errors)].join(' · '); document.querySelector('.is-invalid')?.focus(); return false; }
|
||||||
@ -195,15 +319,6 @@
|
|||||||
const statuses = JSON.parse(setting.value || '[]');
|
const statuses = JSON.parse(setting.value || '[]');
|
||||||
if (Array.isArray(statuses)) statuses.forEach(s => { const value = typeof s === 'string' ? s : s.value; if (value && !Array.from($('status').options).some(o => o.value === value)) $('status').add(new Option(typeof s === 'string' ? s : (s.label || value), value)); });
|
if (Array.isArray(statuses)) statuses.forEach(s => { const value = typeof s === 'string' ? s : s.value; if (value && !Array.from($('status').options).some(o => o.value === value)) $('status').add(new Option(typeof s === 'string' ? s : (s.label || value), value)); });
|
||||||
} catch { /* Keep the built-in statuses available. */ }
|
} catch { /* Keep the built-in statuses available. */ }
|
||||||
const headings = Array.from(form.querySelectorAll(':scope > h5'));
|
|
||||||
panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], true);
|
|
||||||
for (const [key,id,title] of [['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]);
|
|
||||||
const metadata = headings[1]; panel('metadata', 'Status, ansvar og deadline', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling]);
|
|
||||||
const tags = document.createElement('section'); tags.innerHTML = '<div id="cc-tag-chips"></div><button type="button" id="cc-tag-add" class="btn btn-sm btn-outline-primary">Tilføj tag</button><div id="cc-tag-suggestions" class="cc-tag-suggestions"></div>'; state.panels.hardware.before(tags); panel('tags', 'Tags', [tags]);
|
|
||||||
$('cc-tag-add').onclick = () => window.tagPicker.showSelection(addTag);
|
|
||||||
window.setTagPickerContext?.('case', null, addTag);
|
|
||||||
$('cc-tag-chips').onclick = e => { const chip = e.target.closest('[data-tag-id]'); if (chip && e.target.closest('button')) { state.tags = state.tags.filter(t => t.id !== Number(chip.dataset.tagId)); renderTags(); scheduleSave(); } };
|
|
||||||
$('cc-tag-suggestions').onclick = e => { const button = e.target.closest('[data-suggested-tag]'); const tag = state.tagCatalog.find(item => item.id === Number(button?.dataset.suggestedTag)); if (tag) addTag(tag).catch(error => showCreateError(error.message)); };
|
|
||||||
const chips = document.createElement('div'); chips.id = 'cc-hardware-chips'; $('hardwareList').before(chips);
|
const chips = document.createElement('div'); chips.id = 'cc-hardware-chips'; $('hardwareList').before(chips);
|
||||||
chips.onclick = e => { const b = e.target.closest('[data-remove-hardware]'); if (b) { state.hardware = state.hardware.filter(h => h.id !== Number(b.dataset.removeHardware)); renderSelectedHardware(); scheduleSave(); } };
|
chips.onclick = e => { const b = e.target.closest('[data-remove-hardware]'); if (b) { state.hardware = state.hardware.filter(h => h.id !== Number(b.dataset.removeHardware)); renderSelectedHardware(); scheduleSave(); } };
|
||||||
const originalRenderHardware = window.renderHardwareList;
|
const originalRenderHardware = window.renderHardwareList;
|
||||||
@ -216,19 +331,20 @@
|
|||||||
try { const item = await api('/api/v1/hardware/quick', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,customer_id:customerId,anydesk_id:anydeskId || null,anydesk_link:anydeskId ? `anydesk://${anydeskId}` : null})}); state.hardware.push({id:item.id,name}); $('hardwareNameInput').value = ''; $('hardwareAnyDeskIdInput').value = ''; renderSelectedHardware(); scheduleSave(); } catch(e) { showCreateError(e.message); } finally { button.disabled = false; }
|
try { const item = await api('/api/v1/hardware/quick', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,customer_id:customerId,anydesk_id:anydeskId || null,anydesk_link:anydeskId ? `anydesk://${anydeskId}` : null})}); state.hardware.push({id:item.id,name}); $('hardwareNameInput').value = ''; $('hardwareAnyDeskIdInput').value = ''; renderSelectedHardware(); scheduleSave(); } catch(e) { showCreateError(e.message); } finally { button.disabled = false; }
|
||||||
};
|
};
|
||||||
const originalRender = window.renderSelections;
|
const originalRender = window.renderSelections;
|
||||||
window.renderSelections = () => { originalRender(); if (state.ready) { scheduleSave(); scheduleDuplicates(); contactOpenCases(); } };
|
window.renderSelections = () => { originalRender(); if (state.ready) scheduleSave(); };
|
||||||
const contactCases = document.createElement('aside'); contactCases.id = 'cc-contact-cases'; contactCases.className = 'cc-contact-cases'; contactCases.hidden = true; $('selectedContacts').after(contactCases);
|
|
||||||
const workloadEl = document.createElement('div'); workloadEl.id = 'cc-workload'; workloadEl.className = 'cc-workload'; state.panels.metadata.after(workloadEl);
|
|
||||||
const duplicateEl = document.createElement('div'); duplicateEl.id = 'cc-duplicates'; $('titel').closest('.row').after(duplicateEl);
|
|
||||||
const toolbar = document.createElement('div'); toolbar.className = 'mb-3'; toolbar.innerHTML = '<label class="form-label" for="cc-template">Hurtigskabelon</label><select id="cc-template" class="form-select"><option value="">Start uden skabelon</option></select><small id="cc-template-error" class="text-muted"></small>'; form.prepend(toolbar);
|
|
||||||
try { templates = await api('/api/v1/case-create/templates'); $('cc-template').insertAdjacentHTML('beforeend',templates.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('')); } catch { $('cc-template-error').textContent = 'Skabeloner er ikke tilgængelige.'; }
|
|
||||||
$('cc-template').onchange = e => applyTemplate(e.target.value).catch(err => showCreateError(err.message));
|
|
||||||
const actions = $('submitBtn').parentElement; actions.classList.add('cc-actions'); actions.insertAdjacentHTML('afterbegin','<small id="cc-save" role="status" class="me-auto align-self-center">Klargør kladde…</small>');
|
const actions = $('submitBtn').parentElement; actions.classList.add('cc-actions'); actions.insertAdjacentHTML('afterbegin','<small id="cc-save" role="status" class="me-auto align-self-center">Klargør kladde…</small>');
|
||||||
toolbar.classList.add('cc-template-toolbar');
|
// Core flow stays visible: type → required fields → chosen type's fields.
|
||||||
buildTypes(); updateCaseTypeSections();
|
const typeSection = form.querySelector('.cc-type-section');
|
||||||
|
const basicSection = $('titel').closest('.row');
|
||||||
|
const relationsSection = $('customerSearch').closest('.row');
|
||||||
|
const assignmentSection = $('ansvarlig_bruger_id').closest('.row');
|
||||||
|
const moduleAction = $('typeModuleAction');
|
||||||
|
if (typeSection && basicSection) typeSection.after(basicSection);
|
||||||
|
if (basicSection && relationsSection) basicSection.after(relationsSection);
|
||||||
|
if (assignmentSection && moduleAction) assignmentSection.after(moduleAction);
|
||||||
|
buildTypes(); updateCaseTypeSections(false);
|
||||||
form.addEventListener('input', scheduleSave); form.addEventListener('change', scheduleSave); form.addEventListener('click', () => setTimeout(scheduleSave,0));
|
form.addEventListener('input', scheduleSave); form.addEventListener('change', scheduleSave); form.addEventListener('click', () => setTimeout(scheduleSave,0));
|
||||||
$('titel').addEventListener('input', () => { scheduleDuplicates(); scheduleTagSuggestions(); });
|
$('titel').addEventListener('input', scheduleSave);
|
||||||
$('beskrivelse').addEventListener('input', scheduleTagSuggestions); $('ansvarlig_bruger_id').addEventListener('change', workload);
|
|
||||||
document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !document.querySelector('.modal.show')) { e.preventDefault(); if (!$('submitBtn').disabled) form.requestSubmit(); } });
|
document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !document.querySelector('.modal.show')) { e.preventDefault(); if (!$('submitBtn').disabled) form.requestSubmit(); } });
|
||||||
window.addEventListener('pagehide', save);
|
window.addEventListener('pagehide', save);
|
||||||
try {
|
try {
|
||||||
@ -246,10 +362,10 @@
|
|||||||
}
|
}
|
||||||
$('cc-save').textContent = draft ? 'Vælg om kladden skal gendannes' : 'Kladde gemmes i denne browser';
|
$('cc-save').textContent = draft ? 'Vælg om kladden skal gendannes' : 'Kladde gemmes i denne browser';
|
||||||
} catch { $('cc-save').textContent = 'Autosave er ikke tilgængelig'; }
|
} catch { $('cc-save').textContent = 'Autosave er ikke tilgængelig'; }
|
||||||
state.tagCatalog = await api('/api/v1/tags?is_active=true').catch(() => []);
|
state.ready = true;
|
||||||
state.ready = true; summaries(); renderTagSuggestions(); workload(); scheduleDuplicates(); contactOpenCases(); await loadHardwareForContacts();
|
await loadHardwareForContacts();
|
||||||
}
|
}
|
||||||
window.caseCreateUI = {init,typeChanged,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => ({tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id)}),created: result => {
|
window.caseCreateUI = {init,typeChanged,nextType:selectNextType,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => { captureTypeDetails(); return {tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id),type_details:state.typeDetails[$('type').value] || {}}; },created: result => {
|
||||||
state.completed = true; clearTimeout(saveTimer);
|
state.completed = true; clearTimeout(saveTimer);
|
||||||
try { if (state.key) localStorage.removeItem(state.key); if (result.tag_actions?.length) sessionStorage.setItem(`bmc:case-tag-actions:${result.id}`, JSON.stringify(result.tag_actions)); } catch { /* Case is already saved. */ }
|
try { if (state.key) localStorage.removeItem(state.key); if (result.tag_actions?.length) sessionStorage.setItem(`bmc:case-tag-actions:${result.id}`, JSON.stringify(result.tag_actions)); } catch { /* Case is already saved. */ }
|
||||||
}};
|
}};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const root=document.querySelector('.planner-page'); if(!root)return;
|
const root=document.querySelector('.planner-page'); if(!root)return;
|
||||||
const state={view:'week',scope:'mine',anchor:new Date(),meta:null,data:null,cases:[],quick:'unplanned',selected:null,caseRefresh:0};
|
const state={view:'week',scope:'mine',anchor:new Date(),meta:null,data:null,cases:[],overdueCases:[],quick:'unplanned',selected:null,caseRefresh:0};
|
||||||
const $=s=>document.querySelector(s), pad=n=>String(n).padStart(2,'0');
|
const $=s=>document.querySelector(s), pad=n=>String(n).padStart(2,'0');
|
||||||
const localISO=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
const localISO=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
const dateKey=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
const dateKey=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
||||||
@ -12,9 +12,10 @@
|
|||||||
async function init(){state.meta=await api('/api/v1/planner/metadata');for(const u of state.meta.users){$('#responsibleFilter').insertAdjacentHTML('beforeend',`<option value="${u.id}">${esc(u.name)}</option>`)}for(const d of state.meta.departments||[]){$('#departmentFilter').insertAdjacentHTML('beforeend',`<option value="${d.id}">${esc(d.name)}</option>`)}bind();await Promise.all([loadCases(),loadPeriod()]);setInterval(()=>{if(!document.hidden)loadPeriod()},30000)}
|
async function init(){state.meta=await api('/api/v1/planner/metadata');for(const u of state.meta.users){$('#responsibleFilter').insertAdjacentHTML('beforeend',`<option value="${u.id}">${esc(u.name)}</option>`)}for(const d of state.meta.departments||[]){$('#departmentFilter').insertAdjacentHTML('beforeend',`<option value="${d.id}">${esc(d.name)}</option>`)}bind();await Promise.all([loadCases(),loadPeriod()]);setInterval(()=>{if(!document.hidden)loadPeriod()},30000)}
|
||||||
function bind(){let timer;$('#caseSearch').addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(loadCases,250)});['statusFilter','priorityFilter','typeFilter','responsibleFilter','departmentFilter','sortFilter'].forEach(id=>$('#'+id).addEventListener('change',loadCases));document.querySelectorAll('[data-quick]').forEach(b=>b.onclick=()=>{state.quick=state.quick===b.dataset.quick?'':b.dataset.quick;document.querySelectorAll('[data-quick]').forEach(x=>x.classList.toggle('active',x.dataset.quick===state.quick));loadCases()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>{state.view=b.dataset.view;document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});document.querySelectorAll('[data-scope]').forEach(b=>b.onclick=()=>{state.scope=b.dataset.scope;document.querySelectorAll('[data-scope]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});$('#prevPeriod').onclick=()=>shift(-1);$('#nextPeriod').onclick=()=>shift(1);$('#todayBtn').onclick=()=>{state.anchor=new Date();loadPeriod()};$('.collapse-cases').onclick=()=>$('#casePanel').classList.toggle('collapsed');$('#saveAllocation').onclick=saveSelected;$('#deleteAllocation').onclick=deleteSelected;$('#copyAllocation').onclick=copySelected}
|
function bind(){let timer;$('#caseSearch').addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(loadCases,250)});['statusFilter','priorityFilter','typeFilter','responsibleFilter','departmentFilter','sortFilter'].forEach(id=>$('#'+id).addEventListener('change',loadCases));document.querySelectorAll('[data-quick]').forEach(b=>b.onclick=()=>{state.quick=state.quick===b.dataset.quick?'':b.dataset.quick;document.querySelectorAll('[data-quick]').forEach(x=>x.classList.toggle('active',x.dataset.quick===state.quick));loadCases()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>{state.view=b.dataset.view;document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});document.querySelectorAll('[data-scope]').forEach(b=>b.onclick=()=>{state.scope=b.dataset.scope;document.querySelectorAll('[data-scope]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});$('#prevPeriod').onclick=()=>shift(-1);$('#nextPeriod').onclick=()=>shift(1);$('#todayBtn').onclick=()=>{state.anchor=new Date();loadPeriod()};$('.collapse-cases').onclick=()=>$('#casePanel').classList.toggle('collapsed');$('#saveAllocation').onclick=saveSelected;$('#deleteAllocation').onclick=deleteSelected;$('#copyAllocation').onclick=copySelected}
|
||||||
function shift(n){if(state.view==='month')state.anchor.setMonth(state.anchor.getMonth()+n);else state.anchor.setDate(state.anchor.getDate()+n*(state.view==='week'?7:1));loadPeriod()}
|
function shift(n){if(state.view==='month')state.anchor.setMonth(state.anchor.getMonth()+n);else state.anchor.setDate(state.anchor.getDate()+n*(state.view==='week'?7:1));loadPeriod()}
|
||||||
async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{state.cases=(await api('/api/v1/planner/cases?'+p)).items;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const current=$('#typeFilter').value;$('#typeFilter').innerHTML='<option value="">Alle sagstyper</option>'+types.map(x=>`<option ${x===current?'selected':''}>${esc(x)}</option>`).join('');renderCases()}catch(e){$('#caseList').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
|
async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial','overdue'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{const current=await api('/api/v1/planner/cases?'+p);let overdueItems=[];try{overdueItems=(await api('/api/v1/planner/cases?planning=overdue&sort=priority')).items||[]}catch(error){console.warn('Kunne ikke hente overskredne planer',error)}state.cases=current.items;state.overdueCases=overdueItems;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const currentType=$('#typeFilter').value;$('#typeFilter').innerHTML='<option value="">Alle sagstyper</option>'+types.map(x=>`<option ${x===currentType?'selected':''}>${esc(x)}</option>`).join('');renderCases()}catch(e){$('#caseList').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
|
||||||
function setCompactDragImage(e,el){const ghost=document.createElement('div');ghost.textContent=el.querySelector('strong')?.textContent||'Planlæg sag';ghost.style.cssText='position:fixed;left:-9999px;top:-9999px;width:210px;padding:7px 10px;border-radius:7px;border-left:4px solid #2563eb;background:#dbeafe;color:#101828;font-size:12px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 5px 14px #0003';document.body.appendChild(ghost);e.dataTransfer.setDragImage(ghost,18,16);setTimeout(()=>ghost.remove(),0)}
|
function setCompactDragImage(e,el){const ghost=document.createElement('div');ghost.textContent=el.querySelector('strong')?.textContent||'Planlæg sag';ghost.style.cssText='position:fixed;left:-9999px;top:-9999px;width:210px;padding:7px 10px;border-radius:7px;border-left:4px solid #2563eb;background:#dbeafe;color:#101828;font-size:12px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 5px 14px #0003';document.body.appendChild(ghost);e.dataTransfer.setDragImage(ghost,18,16);setTimeout(()=>ghost.remove(),0)}
|
||||||
function renderCases(){$('#caseList').innerHTML=state.cases.length?state.cases.map(c=>`<article class="case-card" draggable="true" data-case="${c.id}" data-priority="${esc(c.priority)}"><strong>#${c.id} · ${esc(c.title)}</strong><div class="customer">${esc(c.customer_name)} · ${esc(c.responsible_name||'Ikke tildelt')}</div><div class="case-meta"><span>${esc(c.priority)}</span><button type="button" class="btn btn-link btn-sm p-0 estimate-time" data-estimate-case="${c.id}" title="Sæt forventet tid"><i class="bi bi-hourglass-split"></i> Forventet ${fmtMin(c.estimated_minutes)}</button></div><div class="case-meta"><span>${fmtMin(c.planned_minutes)} planlagt</span><span>${fmtMin(c.remaining_minutes)} tilbage</span></div><button type="button" class="btn btn-link btn-sm p-0 mt-1 used-time" data-time-case="${c.id}" title="Se hvem der registrerede tid og hvornår"><i class="bi bi-stopwatch"></i> ${fmtMin(c.used_minutes)} brugt${c.latest_registration_at?' · senest '+new Date(c.latest_registration_at).toLocaleDateString('da-DK'):''}</button><div class="progress"><div class="progress-bar" style="width:${c.estimated_minutes?Math.min(100,c.planned_minutes*100/c.estimated_minutes):0}%"></div></div></article>`).join(''):'<div class="empty">Ingen sager matcher filtrene</div>';document.querySelectorAll('.case-card').forEach(el=>el.ondragstart=e=>{e.dataTransfer.setData('application/x-case',el.dataset.case);setCompactDragImage(e,el)});document.querySelectorAll('[data-time-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();showTimeEntries(Number(el.dataset.timeCase))});document.querySelectorAll('[data-estimate-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();setEstimate(Number(el.dataset.estimateCase))})}
|
function caseCardHtml(c){const overdue=Boolean(c.overdue_planned),overdueDate=overdue&&c.overdue_planned_until?new Date(c.overdue_planned_until).toLocaleDateString('da-DK'):'';return `<article class="case-card ${overdue?'overdue-planned':''}" draggable="true" data-case="${c.id}" data-priority="${esc(c.priority)}"><strong>#${c.id} · ${esc(c.title)}</strong><div class="customer">${esc(c.customer_name)} · ${esc(c.responsible_name||'Ikke tildelt')}</div>${overdue?`<div class="case-overdue-label"><i class="bi bi-exclamation-triangle-fill"></i>Planlagt afsluttet ${esc(overdueDate)} · stadig åben</div>`:''}<div class="case-meta"><span>${esc(c.priority)}</span><button type="button" class="btn btn-link btn-sm p-0 estimate-time" data-estimate-case="${c.id}" title="Sæt forventet tid"><i class="bi bi-hourglass-split"></i> Forventet ${fmtMin(c.estimated_minutes)}</button></div><div class="case-meta"><span>${fmtMin(c.planned_minutes)} planlagt</span><span>${fmtMin(c.remaining_minutes)} tilbage</span></div><button type="button" class="btn btn-link btn-sm p-0 mt-1 used-time" data-time-case="${c.id}" title="Se hvem der registrerede tid og hvornår"><i class="bi bi-stopwatch"></i> ${fmtMin(c.used_minutes)} brugt${c.latest_registration_at?' · senest '+new Date(c.latest_registration_at).toLocaleDateString('da-DK'):''}</button><div class="progress"><div class="progress-bar" style="width:${c.estimated_minutes?Math.min(100,c.planned_minutes*100/c.estimated_minutes):0}%"></div></div></article>`}
|
||||||
|
function renderCases(){const overdueHtml=state.quick!=='overdue'&&state.overdueCases.length?`<section class="overdue-case-section"><div class="overdue-case-heading"><i class="bi bi-exclamation-triangle-fill"></i>${state.overdueCases.length} åben${state.overdueCases.length===1?' sag er':' sager er'} planlagt før i dag</div>${state.overdueCases.map(caseCardHtml).join('')}</section>`:'';const casesHtml=state.cases.length?state.cases.map(caseCardHtml).join(''):'<div class="empty">Ingen sager matcher filtrene</div>';$('#caseList').innerHTML=overdueHtml+casesHtml;document.querySelectorAll('.case-card').forEach(el=>el.ondragstart=e=>{e.dataTransfer.setData('application/x-case',el.dataset.case);setCompactDragImage(e,el)});document.querySelectorAll('[data-time-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();showTimeEntries(Number(el.dataset.timeCase))});document.querySelectorAll('[data-estimate-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();setEstimate(Number(el.dataset.estimateCase))})}
|
||||||
async function setEstimate(caseId){const c=state.cases.find(x=>x.id===caseId),answer=prompt('Forventet tidsforbrug i timer (fx 2,5):',c?.estimated_minutes?String(c.estimated_minutes/60).replace('.',','):'');if(answer===null)return;const hours=Number(answer.replace(',','.'));if(!Number.isFinite(hours)||hours<0){toast('Angiv et gyldigt antal timer');return}try{await api(`/api/v1/planner/cases/${caseId}/estimate`,{method:'PATCH',body:JSON.stringify({estimated_minutes:Math.round(hours*60)})});toast('Forventet tid er gemt');await loadCases()}catch(e){toast(e.message)}}
|
async function setEstimate(caseId){const c=state.cases.find(x=>x.id===caseId),answer=prompt('Forventet tidsforbrug i timer (fx 2,5):',c?.estimated_minutes?String(c.estimated_minutes/60).replace('.',','):'');if(answer===null)return;const hours=Number(answer.replace(',','.'));if(!Number.isFinite(hours)||hours<0){toast('Angiv et gyldigt antal timer');return}try{await api(`/api/v1/planner/cases/${caseId}/estimate`,{method:'PATCH',body:JSON.stringify({estimated_minutes:Math.round(hours*60)})});toast('Forventet tid er gemt');await loadCases()}catch(e){toast(e.message)}}
|
||||||
async function showTimeEntries(caseId){const c=state.cases.find(x=>x.id===caseId);$('#timeDialogTitle').textContent=`#${caseId} · ${c?.title||'Sag'}`;$('#timeDialogTotal').textContent='Henter tidsregistreringer…';$('#timeEntryList').innerHTML='';$('#timeDialogCaseLink').href=`/sag/${caseId}/v3`;$('#timeDialog').showModal();try{const data=await api(`/api/v1/planner/cases/${caseId}/time-entries`);$('#timeDialogTotal').textContent=`I alt ${fmtMin(data.total_minutes)} brugt`;$('#timeEntryList').innerHTML=data.items.length?data.items.map(x=>{const when=x.start_tid?new Date(x.start_tid).toLocaleString('da-DK'):(x.worked_date?new Date(x.worked_date+'T12:00').toLocaleDateString('da-DK'):'Ukendt tidspunkt');return `<div class="border rounded p-2"><div class="d-flex justify-content-between"><strong>${esc(x.employee_name)}</strong><span>${fmtMin(x.minutes)}</span></div><small class="text-muted">${esc(when)}${x.slut_tid?' – '+new Date(x.slut_tid).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}):''}</small>${x.description?`<div class="small mt-1">${esc(x.description)}</div>`:''}</div>`}).join(''):'<div class="text-muted">Ingen tid registreret endnu.</div>'}catch(e){$('#timeDialogTotal').textContent=e.message}}
|
async function showTimeEntries(caseId){const c=state.cases.find(x=>x.id===caseId);$('#timeDialogTitle').textContent=`#${caseId} · ${c?.title||'Sag'}`;$('#timeDialogTotal').textContent='Henter tidsregistreringer…';$('#timeEntryList').innerHTML='';$('#timeDialogCaseLink').href=`/sag/${caseId}/v3`;$('#timeDialog').showModal();try{const data=await api(`/api/v1/planner/cases/${caseId}/time-entries`);$('#timeDialogTotal').textContent=`I alt ${fmtMin(data.total_minutes)} brugt`;$('#timeEntryList').innerHTML=data.items.length?data.items.map(x=>{const when=x.start_tid?new Date(x.start_tid).toLocaleString('da-DK'):(x.worked_date?new Date(x.worked_date+'T12:00').toLocaleDateString('da-DK'):'Ukendt tidspunkt');return `<div class="border rounded p-2"><div class="d-flex justify-content-between"><strong>${esc(x.employee_name)}</strong><span>${fmtMin(x.minutes)}</span></div><small class="text-muted">${esc(when)}${x.slut_tid?' – '+new Date(x.slut_tid).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}):''}</small>${x.description?`<div class="small mt-1">${esc(x.description)}</div>`:''}</div>`}).join(''):'<div class="text-muted">Ingen tid registreret endnu.</div>'}catch(e){$('#timeDialogTotal').textContent=e.message}}
|
||||||
async function loadPeriod(){const [a,b]=range(),p=new URLSearchParams({starts_at:a.toISOString(),ends_at:b.toISOString()});selectedUsers().forEach(id=>p.append('user_id',id));$('#timeline').innerHTML='<div class="empty">Henter plan…</div>';try{state.data=await api('/api/v1/planner/period?'+p);render(a,b)}catch(e){$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
|
async function loadPeriod(){const [a,b]=range(),p=new URLSearchParams({starts_at:a.toISOString(),ends_at:b.toISOString()});selectedUsers().forEach(id=>p.append('user_id',id));$('#timeline').innerHTML='<div class="empty">Henter plan…</div>';try{state.data=await api('/api/v1/planner/period?'+p);render(a,b)}catch(e){$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
|
||||||
|
|||||||
@ -968,7 +968,14 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', connect);
|
// The script is included globally and may be loaded after DOMContentLoaded
|
||||||
|
// during internal navigation. In that case the old listener never ran and
|
||||||
|
// incoming-call popups silently stopped because no WebSocket was opened.
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', connect, { once: true });
|
||||||
|
} else {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
window.addEventListener('focus', async () => {
|
window.addEventListener('focus', async () => {
|
||||||
if (reconnectDisabledForAuth) {
|
if (reconnectDisabledForAuth) {
|
||||||
const sessionOk = await hasActiveSession();
|
const sessionOk = await hasActiveSession();
|
||||||
|
|||||||
@ -34,54 +34,32 @@ async function page(storage, userId=7, search='') {
|
|||||||
}
|
}
|
||||||
(async()=>{
|
(async()=>{
|
||||||
const dom=await page(),w=dom.window,d=w.document;
|
const dom=await page(),w=dom.window,d=w.document;
|
||||||
assert.equal(d.querySelectorAll('#cc-types > button[data-case-type]').length,4);
|
assert.equal(d.querySelectorAll('#cc-types > button[data-case-type]').length,8);
|
||||||
assert.equal(d.querySelectorAll('#cc-types > button[data-case-message]').length,1);
|
assert.equal(d.querySelectorAll('#cc-types > button[data-case-message]').length,1);
|
||||||
assert.equal(d.querySelectorAll('.cc-panel').length,6);
|
|
||||||
assert.equal(d.querySelectorAll('form#createForm').length,1);
|
assert.equal(d.querySelectorAll('form#createForm').length,1);
|
||||||
for(const id of ['customerSearch','titel','beskrivelse','status','deadline','submitBtn'])assert.ok(d.getElementById(id).closest('#createForm'),id);
|
for(const id of ['customerSearch','contactSearch','titel','beskrivelse','status','deadline','submitBtn'])assert.ok(d.getElementById(id).closest('#createForm'),id);
|
||||||
assert.match(d.getElementById('cc-workload').textContent,/0 åbne sager/);
|
|
||||||
assert.equal(w.caseCreateUI.validate(),false);
|
assert.equal(w.caseCreateUI.validate(),false);
|
||||||
assert.equal(d.getElementById('cc-relations').open,true);
|
assert.ok(d.getElementById('typeDetailsSection').classList.contains('d-none'),'Type fields stay hidden until a type button is selected');
|
||||||
|
d.querySelector('[data-case-type="bug_report"]').click();
|
||||||
|
assert.ok(!d.getElementById('typeModuleAction').classList.contains('d-none'));
|
||||||
|
assert.ok(d.getElementById('typeDetailsSection').classList.contains('d-none'));
|
||||||
|
d.getElementById('openTypeModuleBtn').click();
|
||||||
|
assert.ok(!d.getElementById('typeDetailsSection').classList.contains('d-none'));
|
||||||
|
assert.match(d.getElementById('typeDetailsFields').textContent,/Berørt område/);
|
||||||
w.testSetCustomer();
|
w.testSetCustomer();
|
||||||
await w.loadSelectedCustomerContacts(11);
|
await w.loadSelectedCustomerContacts(11);
|
||||||
const search=d.getElementById('contactSearch'), results=d.getElementById('contactResults');
|
const search=d.getElementById('contactSearch'), results=d.getElementById('contactResults');
|
||||||
assert.ok(results.classList.contains('d-none'),'Selecting company must not open contact results');
|
|
||||||
search.focus();assert.ok(results.classList.contains('d-none'),'Empty focus must not open results');
|
|
||||||
search.value='Fi';search.dispatchEvent(new w.Event('input',{bubbles:true}));assert.ok(!results.classList.contains('d-none'),'Explicit search opens results');
|
search.value='Fi';search.dispatchEvent(new w.Event('input',{bubbles:true}));assert.ok(!results.classList.contains('d-none'),'Explicit search opens results');
|
||||||
search.value='';search.dispatchEvent(new w.Event('input',{bubbles:true}));assert.ok(results.classList.contains('d-none'),'Clearing closes results');
|
|
||||||
w.testSetContact(); await delay(30);
|
|
||||||
assert.match(d.getElementById('cc-contact-cases').textContent,/Eksisterende routerfejl/);
|
|
||||||
assert.match(d.getElementById('cc-contact-cases').innerHTML,/\/sag\/44\/v3/);
|
|
||||||
d.getElementById('titel').value='Test oprettelse';
|
d.getElementById('titel').value='Test oprettelse';
|
||||||
d.getElementById('beskrivelse').value='Router mister forbindelsen';d.getElementById('beskrivelse').dispatchEvent(new w.Event('input',{bubbles:true}));await delay(400);
|
d.getElementById('beskrivelse').value='Router mister forbindelsen';
|
||||||
assert.match(d.getElementById('cc-tag-suggestions').textContent,/Router/);
|
|
||||||
d.getElementById('pipeline_amount').value='123';
|
|
||||||
d.querySelector('[data-case-type="ordre"]').click();
|
|
||||||
w.addOrderLine('sale');d.querySelector('.order-description').value='Router';d.querySelector('.order-amount').value='50';
|
|
||||||
d.querySelector('[data-case-type="ticket"]').click();
|
|
||||||
assert.equal(d.getElementById('pipeline_amount').value,'123');
|
|
||||||
assert.equal(w.caseCreateUI.validate(),true);
|
assert.equal(w.caseCreateUI.validate(),true);
|
||||||
w.confirm=()=>false;
|
|
||||||
d.getElementById('cc-template').value='1';d.getElementById('cc-template').dispatchEvent(new w.Event('change',{bubbles:true}));await delay(30);
|
|
||||||
assert.equal(d.getElementById('titel').value,'Test oprettelse');
|
|
||||||
w.confirm=()=>true;
|
|
||||||
d.getElementById('cc-tag-add').click();
|
|
||||||
d.getElementById('titel').dispatchEvent(new w.Event('input',{bubbles:true}));
|
d.getElementById('titel').dispatchEvent(new w.Event('input',{bubbles:true}));
|
||||||
await delay(650);
|
await delay(650);
|
||||||
const key=Object.keys(w.localStorage)[0];assert.ok(key?.startsWith('bmc:case-draft:v1:7:'), d.getElementById('cc-save').textContent);
|
const key=Object.keys(w.localStorage)[0];assert.ok(key?.startsWith('bmc:case-draft:v1:7:'), d.getElementById('cc-save').textContent);
|
||||||
const draft=JSON.parse(w.localStorage.getItem(key));assert.equal(draft.orders.length,1);assert.equal(draft.tags[0].id,9);
|
|
||||||
const saved={[key]:w.localStorage.getItem(key)};
|
const saved={[key]:w.localStorage.getItem(key)};
|
||||||
const restored=await page(saved);restored.window.document.getElementById('cc-restore').click();await delay(80);
|
const restored=await page(saved);restored.window.document.getElementById('cc-restore').click();await delay(80);
|
||||||
assert.equal(restored.window.document.getElementById('titel').value,'Test oprettelse');
|
assert.equal(restored.window.document.getElementById('titel').value,'Test oprettelse');
|
||||||
assert.equal(restored.window.document.querySelector('.order-description').value,'Router');
|
|
||||||
assert.deepEqual(Array.from(restored.window.caseCreateUI.relations().tag_ids),[9]);
|
|
||||||
restored.window.caseCreateUI.created({id:99});await delay(600);assert.equal(restored.window.localStorage.getItem(key),null);
|
restored.window.caseCreateUI.created({id:99});await delay(600);assert.equal(restored.window.localStorage.getItem(key),null);
|
||||||
const other=await page(saved,8);assert.equal(other.window.document.getElementById('cc-restore'),null);
|
[dom,restored].forEach(x=>x.window.close());
|
||||||
const prefill=await page(saved,7,'?title=Other');assert.equal(prefill.window.document.getElementById('cc-restore'),null);
|
console.log('PASS: simple flow, all type buttons, required fields and draft restore');
|
||||||
const expired=await page({[key]:JSON.stringify({...draft,savedAt:Date.now()-8*86400000})});assert.equal(expired.window.document.getElementById('cc-restore'),null);
|
|
||||||
const td=expired.window.document;td.getElementById('cc-template').value='1';td.getElementById('cc-template').dispatchEvent(new expired.window.Event('change',{bubbles:true}));await delay(40);
|
|
||||||
assert.equal(td.getElementById('titel').value,'Tilbud');assert.equal(td.getElementById('type').value,'pipeline');assert.deepEqual(Array.from(expired.window.caseCreateUI.relations().tag_ids),[9]);
|
|
||||||
assert.equal(new Set(Array.from(d.querySelectorAll('[id]')).map(el=>el.id)).size,d.querySelectorAll('[id]').length,'IDs must remain unique');
|
|
||||||
[dom,restored,other,prefill,expired].forEach(x=>x.window.close());
|
|
||||||
console.log('PASS: panel structure, type switching, validation, tags, autosave, restore, user/prefill isolation, expiration, successful cleanup');
|
|
||||||
})().catch(e=>{console.error(e);process.exit(1);});
|
})().catch(e=>{console.error(e);process.exit(1);});
|
||||||
|
|||||||
@ -111,6 +111,7 @@ def test_ticket_creation_commits_all_optional_data_or_rolls_back(monkeypatch, mi
|
|||||||
monkeypatch.setattr(cases, '_get_user_id_from_request', lambda r: 7)
|
monkeypatch.setattr(cases, '_get_user_id_from_request', lambda r: 7)
|
||||||
monkeypatch.setattr(cases, '_validate_user_id', lambda v: None)
|
monkeypatch.setattr(cases, '_validate_user_id', lambda v: None)
|
||||||
monkeypatch.setattr(cases, '_validate_group_id', lambda v: None)
|
monkeypatch.setattr(cases, '_validate_group_id', lambda v: None)
|
||||||
|
monkeypatch.setattr(cases, '_validate_customer_id', lambda v: None)
|
||||||
monkeypatch.setattr(cases, 'table_has_column', lambda *a: True)
|
monkeypatch.setattr(cases, 'table_has_column', lambda *a: True)
|
||||||
data = {'titel': 'Test', 'customer_id': 3, 'type': 'ticket', 'pipeline': {'amount': 123}, 'hardware_ids': [4], 'tag_ids': [9], 'order_items': [{'description': 'Router', 'amount': 50}]}
|
data = {'titel': 'Test', 'customer_id': 3, 'type': 'ticket', 'pipeline': {'amount': 123}, 'hardware_ids': [4], 'tag_ids': [9], 'order_items': [{'description': 'Router', 'amount': 50}]}
|
||||||
if missing_hardware:
|
if missing_hardware:
|
||||||
@ -123,3 +124,53 @@ def test_ticket_creation_commits_all_optional_data_or_rolls_back(monkeypatch, mi
|
|||||||
assert all(table in sql for table in ['sag_sager','sag_salgsvarer','sag_hardware','entity_tags'])
|
assert all(table in sql for table in ['sag_sager','sag_salgsvarer','sag_hardware','entity_tags'])
|
||||||
case_args = next(args for query, args in conn.cur.calls if 'INSERT INTO sag_sager' in query)
|
case_args = next(args for query, args in conn.cur.calls if 'INSERT INTO sag_sager' in query)
|
||||||
assert 123.0 in case_args
|
assert 123.0 in case_args
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_ticket_forces_bmc_customer_and_creator_as_responsible(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
from app.modules.sag.backend import router as cases
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class Cursor:
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *_): pass
|
||||||
|
def execute(self, sql, args):
|
||||||
|
if 'INSERT INTO sag_sager' in sql:
|
||||||
|
captured['case_args'] = args
|
||||||
|
self._row = {'id': 21, 'customer_id': args[5], 'ansvarlig_bruger_id': args[6]}
|
||||||
|
else:
|
||||||
|
self._row = None
|
||||||
|
def fetchone(self): return self._row
|
||||||
|
def fetchall(self): return []
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
def __init__(self): self.cur = Cursor(); self.committed = False
|
||||||
|
def cursor(self, **_kwargs): return self.cur
|
||||||
|
def commit(self): self.committed = True
|
||||||
|
def rollback(self): pass
|
||||||
|
|
||||||
|
conn = Connection()
|
||||||
|
monkeypatch.setattr(cases, 'get_db_connection', lambda: conn)
|
||||||
|
monkeypatch.setattr(cases, 'release_db_connection', lambda _conn: None)
|
||||||
|
monkeypatch.setattr(cases, '_normalize_case_status', lambda _value: 'åben')
|
||||||
|
monkeypatch.setattr(cases, '_get_user_id_from_request', lambda _request: 7)
|
||||||
|
monkeypatch.setattr(cases, '_validate_user_id', lambda value: captured.setdefault('validated_user', value))
|
||||||
|
monkeypatch.setattr(cases, '_validate_group_id', lambda _value: None)
|
||||||
|
monkeypatch.setattr(cases, '_validate_customer_id', lambda value: captured.setdefault('validated_customer', value))
|
||||||
|
monkeypatch.setattr(cases, 'table_has_column', lambda *_args: True)
|
||||||
|
|
||||||
|
result = asyncio.run(cases.create_sag(None, {
|
||||||
|
'titel': 'Intern opgave',
|
||||||
|
'type': 'intern_ticket',
|
||||||
|
'customer_id': 1662,
|
||||||
|
'ansvarlig_bruger_id': 99,
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert result['id'] == 21
|
||||||
|
assert captured['validated_customer'] == 8
|
||||||
|
assert captured['validated_user'] == 7
|
||||||
|
assert captured['case_args'][5] == 8
|
||||||
|
assert captured['case_args'][6] == 7
|
||||||
|
assert captured['case_args'][8] == 7
|
||||||
|
assert conn.committed is True
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -95,6 +95,17 @@ def test_planner_ui_exposes_views_filters_capacity_used_time_and_undo():
|
|||||||
assert "green" in html and "yellow" in html and "red" in html and "gray" in html
|
assert "green" in html and "yellow" in html and "red" in html and "gray" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_planner_exposes_case_specific_allocations_endpoint():
|
||||||
|
source = Path("app/modules/planner/backend/router.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert '@router.get("/cases/{sag_id}/allocations")' in source
|
||||||
|
assert "def case_allocations(" in source
|
||||||
|
assert "WHERE a.sag_id=%s AND a.deleted_at IS NULL" in source
|
||||||
|
assert '"remaining_minutes"' in source
|
||||||
|
assert '"deferred"' in source
|
||||||
|
assert '"subcases"' in source
|
||||||
|
|
||||||
|
|
||||||
def test_closed_case_is_checked_before_allocation(monkeypatch):
|
def test_closed_case_is_checked_before_allocation(monkeypatch):
|
||||||
monkeypatch.setattr(planner, "_can_edit", lambda *_: True)
|
monkeypatch.setattr(planner, "_can_edit", lambda *_: True)
|
||||||
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"id": 9, "status": "afsluttet", "deleted_at": None})
|
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"id": 9, "status": "afsluttet", "deleted_at": None})
|
||||||
|
|||||||
@ -128,6 +128,27 @@ def test_relation_quick_task_uses_todo_steps_api():
|
|||||||
assert "due_date: due" in template
|
assert "due_date: due" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_primary_case_contact_clears_previous_primary(monkeypatch):
|
||||||
|
queries = []
|
||||||
|
updates = []
|
||||||
|
|
||||||
|
def fake_query(query, params=None, **_kwargs):
|
||||||
|
queries.append((query, params))
|
||||||
|
if "SELECT id FROM sag_kontakter" in query:
|
||||||
|
return []
|
||||||
|
if "INSERT INTO sag_kontakter" in query:
|
||||||
|
return [{"id": 12, "sag_id": 177, "contact_id": 2011, "is_primary": True}]
|
||||||
|
raise AssertionError(query)
|
||||||
|
|
||||||
|
monkeypatch.setattr(sag_router, "execute_query", fake_query)
|
||||||
|
monkeypatch.setattr(sag_router, "execute_update", lambda query, params: updates.append((query, params)))
|
||||||
|
|
||||||
|
result = asyncio.run(sag_router.add_case_contact(177, {"contact_id": 2011, "is_primary": True}))
|
||||||
|
|
||||||
|
assert result["contact_id"] == 2011
|
||||||
|
assert updates == [("UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s", (177,))]
|
||||||
|
|
||||||
|
|
||||||
def test_case_email_forward_supports_latest_mail_and_full_thread_as_new_thread():
|
def test_case_email_forward_supports_latest_mail_and_full_thread_as_new_thread():
|
||||||
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
||||||
|
|
||||||
@ -145,6 +166,94 @@ def test_case_detail_disables_browser_cache_for_fresh_inline_ui():
|
|||||||
assert 'response.headers["Cache-Control"] = "no-store, max-age=0"' in source
|
assert 'response.headers["Cache-Control"] = "no-store, max-age=0"' in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_is_the_canonical_case_view_and_v3_redirects_to_it():
|
||||||
|
source = Path("app/modules/sag/frontend/views.py").read_text()
|
||||||
|
|
||||||
|
assert 'async def sag_detaljer_v4' in source
|
||||||
|
assert 'async def sag_detaljer_v3_legacy' in source
|
||||||
|
assert 'RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)' in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_case_upload_is_available_from_topbar_and_empty_file_module_is_content_driven():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
|
||||||
|
assert 'bi bi-paperclip' in template
|
||||||
|
assert "document.getElementById('fileInput').click()" in template
|
||||||
|
assert "'files'" in template
|
||||||
|
assert "v4ContentDrivenModules" in template
|
||||||
|
assert "caseTopbarUploadBtn" in template
|
||||||
|
assert "handleTopbarAttachmentFiles" in template
|
||||||
|
assert "uploadEmailFile(emailFile)" in template
|
||||||
|
assert "fil${count === 1 ? '' : 'er'} uploadet" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_file_module_renders_clickable_inline_preview_thumbnails():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
|
||||||
|
assert "case-file-thumbnail" in template
|
||||||
|
assert "previewFileById(${fileId})" in template
|
||||||
|
assert "preview-image?page=1&scale=1" in template
|
||||||
|
assert "bi bi-eye" in template
|
||||||
|
assert "previewAdjacentFile(-1)" in template
|
||||||
|
assert "previewAdjacentFile(1)" in template
|
||||||
|
assert "event.key === 'ArrowLeft'" in template
|
||||||
|
assert "event.key === 'ArrowRight'" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_email_view_can_recover_missing_declared_attachments():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
|
||||||
|
assert "const attachmentsMissing" in template
|
||||||
|
assert "Hent vedhæftninger" in template
|
||||||
|
assert "/recover-attachments" in template
|
||||||
|
assert "recoverLinkedEmailAttachments" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_header_has_no_duplicate_status_or_type_and_uses_contact_widget():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
header = template.split("<!-- WIDGET DASHBOARD -->", 1)[0]
|
||||||
|
|
||||||
|
assert 'id="caseStatusBadge"' not in header
|
||||||
|
assert 'id="caseTypeDropdownBtn"' not in header
|
||||||
|
assert "showCustomerSearch('replace')" not in header
|
||||||
|
assert "showContactSearch('replace')" not in header
|
||||||
|
assert "showAfdelingModal()" not in header
|
||||||
|
assert "Kontakt\n </div>" in template
|
||||||
|
assert "Firma</span>" in template
|
||||||
|
assert "Hovedkontakt</span>" in template
|
||||||
|
assert "Afdeling</span>" in template
|
||||||
|
assert "Start VoIP-kald" in template
|
||||||
|
assert "Send SMS" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_contact_picker_loads_company_contacts_before_global_search():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
|
||||||
|
assert "loadCustomerContactsForContactSearch" in template
|
||||||
|
assert "/api/v1/customers/${customerId}/contacts" in template
|
||||||
|
assert "Firmaets kontakter" in template
|
||||||
|
assert "Andre kontakter" in template
|
||||||
|
assert "Søg efter anden kontakt" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploaded_email_attachments_are_copied_to_case_files():
|
||||||
|
router_source = Path("app/modules/sag/backend/router.py").read_text()
|
||||||
|
|
||||||
|
assert "_copy_uploaded_email_attachments_to_case" in router_source
|
||||||
|
assert 'attachment_files_created' in router_source
|
||||||
|
assert 'source_type")' in router_source
|
||||||
|
|
||||||
|
|
||||||
|
def test_buzzwords_preference_is_per_user_without_changing_legacy_module_preferences():
|
||||||
|
router_source = Path("app/modules/sag/backend/router.py").read_text()
|
||||||
|
migration = Path("migrations/1042_user_sag_module_prefs.sql").read_text()
|
||||||
|
|
||||||
|
assert "user_sag_module_prefs" in migration
|
||||||
|
assert "UNIQUE (user_id, sag_id, module_key)" in migration
|
||||||
|
assert "module_key <> 'buzzwords'" in router_source
|
||||||
|
assert "INSERT INTO user_sag_module_prefs" in router_source
|
||||||
|
|
||||||
|
|
||||||
def test_case_create_lists_contacts_for_selected_customer():
|
def test_case_create_lists_contacts_for_selected_customer():
|
||||||
template = Path("app/modules/sag/templates/create.html").read_text()
|
template = Path("app/modules/sag/templates/create.html").read_text()
|
||||||
|
|
||||||
@ -156,12 +265,40 @@ def test_case_create_lists_contacts_for_selected_customer():
|
|||||||
assert "resetCustomerContactSearch();" in template
|
assert "resetCustomerContactSearch();" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_create_uses_simple_type_first_flow_with_required_description():
|
||||||
|
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
|
||||||
|
script = Path("static/js/case-create.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'id="beskrivelse"' in template
|
||||||
|
assert 'placeholder="Beskriv problemstillingen detaljeret..." required' in template
|
||||||
|
assert 'id="advancedCaseFields"' in template
|
||||||
|
assert "keys.map(button).join('')" in script
|
||||||
|
assert "keys.slice(0,4)" not in script
|
||||||
|
assert "Beskrivelse er påkrævet" in script
|
||||||
|
assert "type → required fields → chosen type's fields" in script
|
||||||
|
assert 'id="typeModuleAction"' in template
|
||||||
|
assert "data-case-module" in script
|
||||||
|
assert "Ticket-oplysninger" in script
|
||||||
|
assert "['ticket', 'bogholderi', 'admin']" in script
|
||||||
|
assert "Virksomhed er påkrævet for denne sagstype" in script
|
||||||
|
assert "Kontakt er påkrævet for denne sagstype" in script
|
||||||
|
|
||||||
|
|
||||||
def test_case_create_defaults_responsible_to_current_user():
|
def test_case_create_defaults_responsible_to_current_user():
|
||||||
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
|
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
|
||||||
router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
|
router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
|
||||||
|
create_script = Path("static/js/case-create.js").read_text(encoding="utf-8")
|
||||||
assert "selectCurrentUserAsResponsible()" in template
|
assert "selectCurrentUserAsResponsible()" in template
|
||||||
|
assert "if (!select || select.value) return" not in template
|
||||||
|
assert "updateCaseTypeSections(false);" in template
|
||||||
assert "raw_responsible = data.get" in router
|
assert "raw_responsible = data.get" in router
|
||||||
assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router
|
assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router
|
||||||
|
assert 'if case_type == "intern_ticket":' in router
|
||||||
|
assert 'data["customer_id"] = 8' in router
|
||||||
|
assert "raw_responsible = current_user_id" in router
|
||||||
|
assert '_validate_customer_id(data.get("customer_id"))' in router
|
||||||
|
assert "selectedCustomer = {id: 8, name: 'BMC Networks'}" in create_script
|
||||||
|
assert "window.caseCreateCurrentUserId" in create_script
|
||||||
|
|
||||||
|
|
||||||
def test_case_create_sends_relations_in_atomic_create_payload():
|
def test_case_create_sends_relations_in_atomic_create_payload():
|
||||||
@ -172,7 +309,7 @@ def test_case_create_sends_relations_in_atomic_create_payload():
|
|||||||
assert 'raw_contact_ids = data.get("contact_ids")' in router
|
assert 'raw_contact_ids = data.get("contact_ids")' in router
|
||||||
assert "INSERT INTO sag_kontakter" in router
|
assert "INSERT INTO sag_kontakter" in router
|
||||||
assert "UPDATE telefoni_opkald" in router
|
assert "UPDATE telefoni_opkald" in router
|
||||||
assert "window.location.href = `/sag/${result.id}/v3`;" in template
|
assert "window.location.href = `/sag/${result.id}/v4`;" in template
|
||||||
assert "Omdirigerer..." not in template
|
assert "Omdirigerer..." not in template
|
||||||
|
|
||||||
|
|
||||||
@ -350,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"]}
|
||||||
|
|
||||||
@ -457,6 +647,158 @@ def test_sag_list_status_dropdown_receives_options_and_links_are_styled():
|
|||||||
assert "function applyStatusSelectTone(control)" in template
|
assert "function applyStatusSelectTone(control)" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_compact_time_module_is_moved_below_tags_and_creates_drafts():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
|
||||||
|
assert 'id="caseQuickTimeMinutes"' in template
|
||||||
|
assert 'id="caseQuickTimeType"' in template
|
||||||
|
assert 'id="caseQuickTimeDescription"' in template
|
||||||
|
assert 'onclick="startQuickTimerV4()"' in template
|
||||||
|
assert "tagsModule.insertAdjacentElement('afterend', timeModule);" in template
|
||||||
|
assert "entry_status: 'kladde'" in template
|
||||||
|
assert "medarbejder_ids: [null]" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_postit_keeps_tags_time_and_full_time_tab_available():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
|
||||||
|
assert ':not([data-module="tags"]):not([data-module="time"])' in template
|
||||||
|
assert "target !== '#details' && target !== '#timetracking' && target !== '#planner'" in template
|
||||||
|
assert "moduleName !== 'tags' && moduleName !== 'time'" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_timeline_date_menus_escape_hero_stacking_context():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
|
||||||
|
assert template.count("data-v4-date-dropdown") >= 2
|
||||||
|
assert "case-v4-floating-date-menu" in template
|
||||||
|
assert "document.body.appendChild(menu);" in template
|
||||||
|
assert "toggle.insertAdjacentElement('afterend', menu);" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_group_assignment_loader_is_available_to_page_initialization():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
|
||||||
|
assert "window.loadCaseAssignments = loadCaseAssignments;" in template
|
||||||
|
assert "window.persistCaseAssignees = persistCaseAssignees;" in template
|
||||||
|
assert "window.loadCaseAssignments?.().catch" in template
|
||||||
|
assert "await persistCaseAssignees([], null, null);" in template
|
||||||
|
assert "await saveCaseAssignees([], null, null);" not in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_deferred_modal_is_compact_and_saves_atomically():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
|
||||||
|
assert 'class="modal fade deferred-v4-modal" id="deferredModal"' in template
|
||||||
|
assert "bootstrap.Modal.getOrCreateInstance(modalElement).show();" in template
|
||||||
|
assert "async function persistDeferredSettings" in template
|
||||||
|
assert "await persistDeferredSettings(input.value || null, caseSelect.value || null, statuses);" in template
|
||||||
|
assert "deferred_until_status: relatedCaseId && normalizedStatuses.length" in template
|
||||||
|
assert "updateDeferredUntil(input.value);" not in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_comment_actions_use_compact_icons_and_can_append_to_case():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
router = Path("app/modules/sag/backend/router.py").read_text()
|
||||||
|
|
||||||
|
assert 'action-time is-icon-only' in template
|
||||||
|
assert 'action-close is-icon-only' in template
|
||||||
|
assert 'action-wait is-icon-only' in template
|
||||||
|
assert '<span>Log tid</span>' not in template
|
||||||
|
assert '<span>Luk sag</span>' not in template
|
||||||
|
assert '<span>Afventer kunde</span>' not in template
|
||||||
|
assert 'id="commentActionAppendCase"' in template
|
||||||
|
assert "actions.push('append_case')" in template
|
||||||
|
assert "body: JSON.stringify({ beskrivelse: content, append: true })" in template
|
||||||
|
assert "await appendCommentToCaseDescription(content);" in template
|
||||||
|
assert "append: bool = False" in router
|
||||||
|
assert 'append_date = datetime.now().strftime("%d/%m")' in router
|
||||||
|
assert 'append_fragment = f"<hr><h6>{initials}/{append_date}</h6><p>{safe_addition}</p>"' in router
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_comment_composer_prioritizes_writing_space():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
|
||||||
|
assert 'min-height: 160px;' in template
|
||||||
|
assert 'rows="6"' in template
|
||||||
|
assert '.comment-composer textarea:focus' in template
|
||||||
|
assert 'align-items: flex-end;' in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_relations_are_compact_and_created_inline():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
|
||||||
|
assert 'Relationer til #{{ case.id }}' in template
|
||||||
|
assert 'id="relationInlineCreate"' in template
|
||||||
|
assert 'onclick="showInlineRelationRow()"' in template
|
||||||
|
assert 'id="inlineRelatedCaseType"' in template
|
||||||
|
assert 'id="inlineRelatedCaseTitle"' in template
|
||||||
|
assert 'id="inlineRelationType"' in template
|
||||||
|
assert 'id="inlineRelatedCaseUser"' in template
|
||||||
|
assert 'id="inlineRelatedCaseGroup"' in template
|
||||||
|
assert 'async function saveInlineRelation()' in template
|
||||||
|
assert "const caseResponse = await fetch('/api/v1/sag'" in template
|
||||||
|
assert 'målsag_id: newCase.id' in template
|
||||||
|
assert 'placeholder="Sag #"' not in template
|
||||||
|
assert 'Skriv et gyldigt sagsnummer.' not in template
|
||||||
|
assert 'ansvarlig_bruger_id: responsibleUserId' in template
|
||||||
|
assert 'assigned_group_id: assignedGroupId' in template
|
||||||
|
assert "node.case.ansvarlig_navn or 'Ingen ansvarlig'" in template
|
||||||
|
assert "node.case.gruppe_navn or 'Ingen gruppe'" in template
|
||||||
|
assert 'class="btn btn-outline-primary btn-rel-action rel-related-add-btn"' in template
|
||||||
|
assert 'onclick=\'openRelQaMenu({{ node.case.id }}' in template
|
||||||
|
assert '.compact-relations-table .btn-rel-action.rel-related-add-btn' in template
|
||||||
|
assert 'href="/sag/{{ node.case.id }}/v4" class="compact-relation-id"' in template
|
||||||
|
assert '<tr class="{{ \'d-none\' if node.is_current else \'\' }}">' in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_embeds_case_planner_with_direct_subcase_creation():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
|
||||||
|
assert 'id="planner-tab" data-bs-target="#planner"' in template
|
||||||
|
assert 'id="casePlannerAllocationForm"' in template
|
||||||
|
assert 'id="casePlannerSubcaseForm"' in template
|
||||||
|
assert "async function createEmbeddedCaseAllocation" in template
|
||||||
|
assert "async function createPlannerSubcase" in template
|
||||||
|
assert "relationstype:'undersag'" in template
|
||||||
|
assert "`/api/v1/planner/cases/${caseIds}/allocations`" in template
|
||||||
|
assert 'id="casePlannerDeferredNotice"' in template
|
||||||
|
assert "Venter på sag #${deferred.case_id}" in template
|
||||||
|
assert "subcase.deferred_until_case_id" in template
|
||||||
|
assert "window.loadEmbeddedCasePlanner?.();" in template
|
||||||
|
assert 'id="casePlannerWeek"' in template
|
||||||
|
assert "function renderEmbeddedPlannerCalendar()" in template
|
||||||
|
assert "function shiftEmbeddedPlannerWeek(direction)" in template
|
||||||
|
assert "Klik på en dag for at allokere tid" in template
|
||||||
|
assert 'id="casePlannerSlotModal"' in template
|
||||||
|
assert "function openEmbeddedPlannerSlot(dateKey)" in template
|
||||||
|
assert "async function saveEmbeddedPlannerSlot(event)" in template
|
||||||
|
assert "Dette er planlagt tid" in template
|
||||||
|
assert "Der oprettes ingen tidsregistrering" in template
|
||||||
|
assert "konfliktadvarsel før overbooking" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_comment_source_filters_start_off_and_use_compact_icons():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
|
||||||
|
for filter_id in ("Email", "Sms", "Call", "Module"):
|
||||||
|
marker = f'id="commentSourceFilter{filter_id}"'
|
||||||
|
line = next(line for line in template.splitlines() if marker in line)
|
||||||
|
assert "checked" not in line
|
||||||
|
for icon in ("bi-envelope", "bi-chat-dots", "bi-telephone", "bi-grid"):
|
||||||
|
assert icon in template
|
||||||
|
assert ".comment-category-check:has(input:checked)" in template
|
||||||
|
assert "const sourceFilterIsActive = selectedSources.size > 0;" in template
|
||||||
|
assert "const matchesSource = !sourceFilterIsActive || selectedSources.has(source);" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_v3_route_renders_the_active_v4_template():
|
||||||
|
views = Path("app/modules/sag/frontend/views.py").read_text()
|
||||||
|
|
||||||
|
assert 'template_name = "modules/sag/templates/detail_v4.html"' in views
|
||||||
|
assert 'else "modules/sag/templates/detail_v3.html"' not in views
|
||||||
|
|
||||||
|
|
||||||
def test_support_case_close_without_time_requires_explicit_confirmation():
|
def test_support_case_close_without_time_requires_explicit_confirmation():
|
||||||
template = Path("app/modules/sag/templates/index.html").read_text()
|
template = Path("app/modules/sag/templates/index.html").read_text()
|
||||||
source = Path("app/modules/sag/backend/router.py").read_text()
|
source = Path("app/modules/sag/backend/router.py").read_text()
|
||||||
@ -470,6 +812,22 @@ def test_support_case_close_without_time_requires_explicit_confirmation():
|
|||||||
assert "await confirmCloseCaseWithoutTime(caseId, detail.message)" in template
|
assert "await confirmCloseCaseWithoutTime(caseId, detail.message)" in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_uses_fixed_case_statuses_and_blocks_unfinished_time_on_close():
|
||||||
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||||
|
views = Path("app/modules/sag/frontend/views.py").read_text()
|
||||||
|
source = Path("app/modules/sag/backend/router.py").read_text()
|
||||||
|
|
||||||
|
expected = '["Ny", "Tildelt", "Igangværende", "Afventer", "Løst", "Kunde svaret", "Lukket"]'
|
||||||
|
assert f"return {expected}" in views
|
||||||
|
assert "case_close_blocked_unfinished_time" in source
|
||||||
|
assert "entry_status" in source
|
||||||
|
assert "<> 'godkendt'" in source
|
||||||
|
assert "slut_tid IS NULL" in source
|
||||||
|
assert "const closesCase = nextStatus.toLowerCase() === 'lukket';" in template
|
||||||
|
assert "Sagen kan kun lukkes, når alle tidsregistreringer er færdigbehandlede" in template
|
||||||
|
assert "showToast(e.message || 'Kunne ikke gemme status', 'danger');" in template
|
||||||
|
|
||||||
|
|
||||||
def test_sag_list_has_smart_toolbar_search_and_employee_quick_filters():
|
def test_sag_list_has_smart_toolbar_search_and_employee_quick_filters():
|
||||||
template = Path("app/modules/sag/templates/index.html").read_text()
|
template = Path("app/modules/sag/templates/index.html").read_text()
|
||||||
source = Path("app/modules/sag/backend/router.py").read_text()
|
source = Path("app/modules/sag/backend/router.py").read_text()
|
||||||
|
|||||||
@ -130,4 +130,6 @@ def test_case_click_to_call_contract_tracks_case_contact_and_time():
|
|||||||
assert 'pending_callid = f"click-to-call:' in router
|
assert 'pending_callid = f"click-to-call:' in router
|
||||||
assert "_register_completed_call_time(resolved_callid)" in router
|
assert "_register_completed_call_time(resolved_callid)" in router
|
||||||
assert "INSERT INTO tmodule_times" in router
|
assert "INSERT INTO tmodule_times" in router
|
||||||
assert "callid LIKE 'click-to-call:%'" in service
|
# psycopg uses % for placeholders, so the literal SQL LIKE wildcard must
|
||||||
|
# be escaped. Otherwise the established Yealink callback returns HTTP 500.
|
||||||
|
assert "callid LIKE 'click-to-call:%%'" in service
|
||||||
|
|||||||
@ -76,6 +76,129 @@ def test_manual_time_bulk_resolves_current_user_and_removes_duplicates(monkeypat
|
|||||||
assert result["count"] == 2
|
assert result["count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_internal_time_can_be_created_without_case_customer(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
monkeypatch.setattr(timetracking_router, "_resolve_case_customer_id", lambda *_args: None)
|
||||||
|
|
||||||
|
def fake_execute_query(_query, params):
|
||||||
|
captured["params"] = params
|
||||||
|
return [{"id": 1, "customer_id": None, "billable": False}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
timetracking_router.create_manual_time_v1(
|
||||||
|
{
|
||||||
|
"sag_id": 177,
|
||||||
|
"medarbejder_ids": [None],
|
||||||
|
"faktisk_tid_min": 15,
|
||||||
|
"entry_status": "kladde",
|
||||||
|
},
|
||||||
|
current_user={"id": 9, "username": "tester"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["count"] == 1
|
||||||
|
assert captured["params"][2] is None
|
||||||
|
assert captured["params"][8] is False
|
||||||
|
assert captured["params"][9] == "internal"
|
||||||
|
assert captured["params"][13] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_customerless_time_recommends_internal_settlement():
|
||||||
|
result = timetracking_router._settlement_options_for_customer(None)
|
||||||
|
|
||||||
|
assert result["recommended"]["method"] == "internal"
|
||||||
|
assert result["prepaid_cards"] == []
|
||||||
|
assert result["agreements"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_settlement_choices_use_authoritative_case_customer_for_prepaid_card(monkeypatch):
|
||||||
|
card_query = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
timetracking_router,
|
||||||
|
"execute_query_single",
|
||||||
|
lambda query, params: {"customer_id": 904} if "FROM sag_sager" in query else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_execute_query(query, params):
|
||||||
|
if "FROM tticket_prepaid_cards" in query:
|
||||||
|
card_query["customer_id"] = params[0]
|
||||||
|
return [{"id": 72, "card_number": "CARD-TEST", "remaining_hours": 4.0}]
|
||||||
|
if "FROM customer_fixed_price_agreements" in query:
|
||||||
|
return []
|
||||||
|
raise AssertionError(query)
|
||||||
|
|
||||||
|
monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query)
|
||||||
|
options = timetracking_router._settlement_options_for_case_entry(
|
||||||
|
{"id": 12, "sag_id": 55, "customer_id": 3}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert card_query["customer_id"] == 904
|
||||||
|
assert options["prepaid_cards"][0]["id"] == 72
|
||||||
|
assert options["recommended"]["method"] == "prepaid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_time_entry_refuses_settled_rows_without_changing_them(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
timetracking_router,
|
||||||
|
"execute_query_single",
|
||||||
|
lambda *_args: {
|
||||||
|
"id": 41,
|
||||||
|
"status": "billed",
|
||||||
|
"description": "Original",
|
||||||
|
"prepaid_card_id": None,
|
||||||
|
"economy_settled_at": None,
|
||||||
|
"economy_order_draft_id": None,
|
||||||
|
"billed_via_thehub_id": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
timetracking_router,
|
||||||
|
"execute_query",
|
||||||
|
lambda *_args: (_ for _ in ()).throw(AssertionError("settled rows must not be updated")),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(timetracking_router.patch_time_entry_v1(41, {"description": "Changed"}))
|
||||||
|
except Exception as exc:
|
||||||
|
assert getattr(exc, "status_code", None) == 409
|
||||||
|
assert "korrektion" in str(getattr(exc, "detail", "")).lower()
|
||||||
|
else:
|
||||||
|
raise AssertionError("settled time entry unexpectedly accepted an edit")
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_time_entry_logs_before_after_for_editable_rows(monkeypatch):
|
||||||
|
existing = {
|
||||||
|
"id": 42,
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Before",
|
||||||
|
"prepaid_card_id": None,
|
||||||
|
"economy_settled_at": None,
|
||||||
|
"economy_order_draft_id": None,
|
||||||
|
"billed_via_thehub_id": None,
|
||||||
|
}
|
||||||
|
updated = {**existing, "description": "After"}
|
||||||
|
monkeypatch.setattr(timetracking_router, "execute_query_single", lambda *_args: existing)
|
||||||
|
monkeypatch.setattr(timetracking_router, "execute_query", lambda *_args: [updated])
|
||||||
|
captured = {}
|
||||||
|
monkeypatch.setattr(timetracking_router.audit, "log_event", lambda **kwargs: captured.update(kwargs) or 5)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
timetracking_router.patch_time_entry_v1(
|
||||||
|
42,
|
||||||
|
{"description": "After", "reason": "Rettelse"},
|
||||||
|
current_user={"id": 9},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["description"] == "After"
|
||||||
|
assert captured["event_type"] == "time_entry_updated"
|
||||||
|
assert captured["user_id"] == 9
|
||||||
|
assert captured["details"]["changes"]["description"] == {"before": "Before", "after": "After"}
|
||||||
|
assert captured["details"]["reason"] == "Rettelse"
|
||||||
|
|
||||||
|
|
||||||
def test_case_time_form_sends_local_time_and_multiple_employees():
|
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