Add comprehensive test coverage for case creation, assignment, and remote recovery
- Introduced tests for case creation commands, ensuring proper handling of UUIDs and payloads. - Added tests for v5 assignment functionality, validating employee identification and group assignment. - Implemented tests for remote recovery scenarios, including handling network failures and response validation. - Created tests for hardware label generation and validation, ensuring proper URL formatting and QR code generation. - Developed extensive tests for remote control functionality, covering connection isolation, command execution, and user permissions. - Added tests for workflow timer registration, ensuring correct handling of timer states and ownership validation.
This commit is contained in:
parent
1de084864d
commit
85e019783b
@ -3,6 +3,9 @@
|
|||||||
# =====================================================
|
# =====================================================
|
||||||
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
|
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
|
||||||
HUB_BASE_URL=https://hub.bmcnetworks.dk
|
HUB_BASE_URL=https://hub.bmcnetworks.dk
|
||||||
|
# Remote control remains disabled until migrated; uses the existing Hub on local network/VPN.
|
||||||
|
REMOTE_ENABLED=false
|
||||||
|
REMOTE_LOOKUP_KEY=
|
||||||
MOBILE_RECORDER_PROVISIONING_TOKEN=replace-with-a-long-random-service-token
|
MOBILE_RECORDER_PROVISIONING_TOKEN=replace-with-a-long-random-service-token
|
||||||
|
|
||||||
# Database credentials (bruges af docker-compose)
|
# Database credentials (bruges af docker-compose)
|
||||||
|
|||||||
@ -37,6 +37,10 @@ class Settings(BaseSettings):
|
|||||||
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
|
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
|
||||||
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk"
|
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk"
|
||||||
|
|
||||||
|
# Local-network/VPN remote control. Configure a separate random key before enabling.
|
||||||
|
REMOTE_ENABLED: bool = False
|
||||||
|
REMOTE_LOOKUP_KEY: str = ""
|
||||||
|
|
||||||
# Non-interactive service token for Apple Configurator/cfgutil provisioning.
|
# Non-interactive service token for Apple Configurator/cfgutil provisioning.
|
||||||
# Leave empty to disable the endpoint rather than accepting unauthenticated calls.
|
# Leave empty to disable the endpoint rather than accepting unauthenticated calls.
|
||||||
MOBILE_RECORDER_PROVISIONING_TOKEN: str = ""
|
MOBILE_RECORDER_PROVISIONING_TOKEN: str = ""
|
||||||
|
|||||||
@ -5,7 +5,7 @@ PostgreSQL connection and helpers using psycopg2
|
|||||||
|
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
from psycopg2.pool import SimpleConnectionPool
|
from psycopg2.pool import ThreadedConnectionPool
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import logging
|
import logging
|
||||||
@ -17,7 +17,7 @@ from app.core.config import settings
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Connection pool
|
# Connection pool
|
||||||
connection_pool: Optional[SimpleConnectionPool] = None
|
connection_pool: Optional[ThreadedConnectionPool] = None
|
||||||
_transaction_connection = ContextVar("bmc_transaction_connection", default=None)
|
_transaction_connection = ContextVar("bmc_transaction_connection", default=None)
|
||||||
|
|
||||||
|
|
||||||
@ -44,7 +44,8 @@ def init_db():
|
|||||||
global connection_pool
|
global connection_pool
|
||||||
|
|
||||||
try:
|
try:
|
||||||
connection_pool = SimpleConnectionPool(
|
# Synchronous API handlers run in worker threads, including remote polls.
|
||||||
|
connection_pool = ThreadedConnectionPool(
|
||||||
minconn=1,
|
minconn=1,
|
||||||
maxconn=10,
|
maxconn=10,
|
||||||
dsn=settings.DATABASE_URL
|
dsn=settings.DATABASE_URL
|
||||||
|
|||||||
@ -438,6 +438,27 @@ async def hardware_eset_import(request: Request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hardware/{hardware_id:int}/label", response_class=HTMLResponse)
|
||||||
|
async def hardware_label(request: Request, hardware_id: int,
|
||||||
|
size: str = Query("62x40"), base_url: Optional[str] = Query(None, max_length=300)):
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.modules.hardware.labels import LABEL_SIZES, hardware_label_url, hardware_label_qr
|
||||||
|
|
||||||
|
if size not in LABEL_SIZES:
|
||||||
|
raise HTTPException(400, detail="Vælg en af de understøttede labelstørrelser")
|
||||||
|
rows = execute_query("SELECT id, brand, model, serial_number FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (hardware_id,))
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(404, detail="Hardware not found")
|
||||||
|
label_base = (base_url or settings.HUB_BASE_URL).strip().rstrip('/')
|
||||||
|
target_url = hardware_label_url(label_base, hardware_id)
|
||||||
|
width, height = LABEL_SIZES[size]
|
||||||
|
return templates.TemplateResponse("modules/hardware/templates/label.html", {
|
||||||
|
"request": request, "hardware": rows[0], "size": size,
|
||||||
|
"width": width, "height": height, "base_url": label_base,
|
||||||
|
"target_url": target_url, "qr_svg": hardware_label_qr(target_url),
|
||||||
|
}, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hardware/{hardware_id:int}", response_class=HTMLResponse)
|
@router.get("/hardware/{hardware_id:int}", response_class=HTMLResponse)
|
||||||
async def hardware_detail(request: Request, hardware_id: int):
|
async def hardware_detail(request: Request, hardware_id: int):
|
||||||
"""Display hardware details."""
|
"""Display hardware details."""
|
||||||
|
|||||||
26
app/modules/hardware/labels.py
Normal file
26
app/modules/hardware/labels.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"""Local QR generation for printable inventory labels. No asset writes."""
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
import qrcode
|
||||||
|
from qrcode.image.svg import SvgPathImage
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
LABEL_SIZES = {'62x40': (62, 40), '62x29': (62, 29), '89x36': (89, 36)}
|
||||||
|
|
||||||
|
|
||||||
|
def hardware_label_url(base_url: str, hardware_id: int) -> str:
|
||||||
|
try:
|
||||||
|
parts = urlsplit(base_url.strip())
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(400, "Hub-adressen er ugyldig") from error
|
||||||
|
if (parts.scheme not in {'http', 'https'} or not parts.hostname
|
||||||
|
or parts.username or parts.password or parts.query or parts.fragment):
|
||||||
|
raise HTTPException(400, 'Brug en http- eller https-adresse uden login, søgeparametre eller fragment.')
|
||||||
|
return urlunsplit((parts.scheme, parts.netloc, parts.path.rstrip('/') + f'/hardware/{hardware_id}', '', ''))
|
||||||
|
|
||||||
|
|
||||||
|
def hardware_label_qr(url: str) -> str:
|
||||||
|
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, border=4)
|
||||||
|
qr.add_data(url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
return qr.make_image(image_factory=SvgPathImage).to_string().decode('utf-8')
|
||||||
@ -259,6 +259,7 @@
|
|||||||
|
|
||||||
<!-- Link to Edit -->
|
<!-- Link to Edit -->
|
||||||
<div class="ms-auto d-flex gap-2">
|
<div class="ms-auto d-flex gap-2">
|
||||||
|
<a href="/hardware/{{ hardware.id }}/label" class="btn btn-sm btn-outline-primary" target="_blank" rel="noopener" title="Print label med QR-kode"><i class="bi bi-qr-code me-1"></i>Label</a>
|
||||||
<button onclick="syncEsetData()" class="btn btn-sm btn-outline-secondary" title="Opdater fra ESET">
|
<button onclick="syncEsetData()" class="btn btn-sm btn-outline-secondary" title="Opdater fra ESET">
|
||||||
<i class="bi bi-arrow-repeat"></i>
|
<i class="bi bi-arrow-repeat"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -394,6 +394,7 @@
|
|||||||
<div class="hardware-actions" onclick="event.stopPropagation()">
|
<div class="hardware-actions" onclick="event.stopPropagation()">
|
||||||
<a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a>
|
<a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a>
|
||||||
<a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a>
|
<a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a>
|
||||||
|
<a href="/hardware/{{ item.id }}/label" class="btn-action" target="_blank" rel="noopener"><i class="bi bi-qr-code"></i> Label</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -447,6 +448,7 @@
|
|||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a>
|
<a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a>
|
||||||
<a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a>
|
<a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a>
|
||||||
|
<a href="/hardware/{{ item.id }}/label" class="btn-action" target="_blank" rel="noopener"><i class="bi bi-qr-code"></i> Label</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
34
app/modules/hardware/templates/label.html
Normal file
34
app/modules/hardware/templates/label.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Label · BMC-{{ '%05d'|format(hardware.id) }}</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box}body{margin:0;background:#e8eef4;color:#243449;font-family:Arial,Helvetica,sans-serif}
|
||||||
|
.controls{max-width:760px;margin:32px auto 22px;padding:22px;background:#fff;border:1px solid #bdcbd5;border-radius:12px}
|
||||||
|
h1{font-size:21px;margin:0 0 7px}p{font-size:13px;color:#65758a;line-height:1.5}form{display:grid;grid-template-columns:150px 1fr;gap:12px}label{font-size:12px;font-weight:600;display:block;margin-bottom:5px}input,select{width:100%;padding:9px;border:1px solid #bdcbd5;border-radius:7px;background:#fff;color:#243449}button,.back{display:inline-block;padding:10px 14px;border-radius:7px;border:1px solid #336699;font-size:13px;cursor:pointer;text-decoration:none}button{background:#336699;color:#fff}.back{background:#fff;color:#336699}.buttons{grid-column:1/-1;display:flex;gap:8px;flex-wrap:wrap}.target{overflow-wrap:anywhere}.preview{display:flex;justify-content:center;padding:10px 12px 40px}
|
||||||
|
.asset-label{width:{{ width }}mm;height:{{ height }}mm;display:grid;grid-template-columns:minmax(0,1fr) {{ 21 if height == 29 else 27 }}mm;gap:1.5mm;padding:2.5mm;background:#fff;color:#111;box-shadow:0 8px 30px #23374d26;outline:1px solid #bdcbd5;overflow:hidden}
|
||||||
|
.label-copy{display:flex;flex-direction:column;min-width:0;justify-content:space-between}.brand{font-size:7pt;font-weight:800;letter-spacing:.3pt;border-bottom:.35mm solid #111;padding-bottom:1mm}.name{font-size:{{ 8 if height == 29 else 10 }}pt;font-weight:700;line-height:1.15;overflow-wrap:anywhere;margin:1.2mm 0;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.asset-id{font-size:9pt;font-weight:800;letter-spacing:.4pt}.serial{font-size:6pt;line-height:1.2;overflow-wrap:anywhere;margin-top:.7mm}.qr{align-self:center;display:flex;flex-direction:column;align-items:center}.qr svg{display:block;width:100%;height:auto}.qr small{font-size:5.5pt;font-weight:600;margin-top:.3mm;text-align:center}
|
||||||
|
@page{size:{{ width }}mm {{ height }}mm;margin:0}
|
||||||
|
@media print{html,body{width:{{ width }}mm;height:{{ height }}mm;background:#fff;margin:0}.controls{display:none}.preview{display:block;padding:0}.asset-label{box-shadow:none;outline:0;break-inside:avoid;print-color-adjust:exact;-webkit-print-color-adjust:exact}}
|
||||||
|
@media(max-width:600px){.controls{margin:12px;padding:16px}form{grid-template-columns:1fr}.buttons{grid-column:1}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section class="controls">
|
||||||
|
<h1>Label til jeres hardware</h1><p>Print i 100 % størrelse uden sidehoved og sidefod. QR-koden åbner hardwarekortet i Hub.</p>
|
||||||
|
<form method="get">
|
||||||
|
<div><label for="size">Labelstørrelse</label><select id="size" name="size">{% for value, text in [('62x40','62 × 40 mm'),('62x29','62 × 29 mm'),('89x36','89 × 36 mm')] %}<option value="{{ value }}" {% if size == value %}selected{% endif %}>{{ text }}</option>{% endfor %}</select></div>
|
||||||
|
<div><label for="base_url">Hub-adresse til QR-koden</label><input type="url" id="base_url" name="base_url" value="{{ base_url }}" required><small>Brug en adresse, telefonen kan åbne.</small></div>
|
||||||
|
<div class="buttons"><button type="submit">Opdater label</button><button type="button" onclick="window.print()">Print label</button><a class="back" href="/hardware/{{ hardware.id }}">Tilbage til delen</a></div>
|
||||||
|
</form>
|
||||||
|
<p class="target">QR-link: <a href="{{ target_url }}">{{ target_url }}</a></p>
|
||||||
|
</section>
|
||||||
|
<div class="preview">
|
||||||
|
<article class="asset-label" aria-label="Hardwarelabel">
|
||||||
|
<div class="label-copy"><div class="brand">BMC NETWORKS · UDSTYR</div><div class="name">{{ hardware.brand or '' }} {{ hardware.model or 'Hardware' }}</div><div><div class="asset-id">BMC-{{ '%05d'|format(hardware.id) }}</div>{% if hardware.serial_number %}<div class="serial">S/N {{ hardware.serial_number }}</div>{% endif %}</div></div>
|
||||||
|
<div class="qr">{{ qr_svg|safe }}<small>Scan for hardwarekort</small></div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
app/modules/remote/__init__.py
Normal file
1
app/modules/remote/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Restricted mobile remote control for BMC Hub."""
|
||||||
6
app/modules/remote/manage.html
Normal file
6
app/modules/remote/manage.html
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<!doctype html><html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Hub · Mine fjernbetjeninger</title><link rel="stylesheet" href="/static/css/remote.css?v=3"></head>
|
||||||
|
<body class="remote-manage"><main><header class="app-header"><div class="brand"><span class="brand-mark" aria-hidden="true">H<span>•</span></span><div><span class="eyebrow">BMC HUB</span><h1>Mine fjernbetjeninger</h1></div></div><a class="back-link" href="/">Til Hub ↗</a></header><p id="notice" role="alert"></p>
|
||||||
|
<section><h2>Din daglige PIN</h2><p>Samme PIN bruges på alle dine enheder. Ændring kræver ny oplåsning på enhederne.</p><form id="pin-form"><label>4 cifre<input id="pin" type="password" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" autocomplete="off" required></label><button>Gem PIN</button></form></section>
|
||||||
|
<section><h2>Opret fjernbetjening</h2><form id="create-form"><label>Navn<input id="name" maxlength="100" required placeholder="Fx arbejdsmobil"></label><button>Opret og vis kode</button></form><div id="new-code" hidden><p>Indtast denne kode og din PIN på mobilen på <a href="/remote">/remote</a>. Koden vises kun her nu.</p><strong id="code" class="clock"></strong><button id="hide-code" class="secondary">Skjul kode</button></div></section>
|
||||||
|
<section><h2>Fjernbetjeninger</h2><div id="remotes"></div></section><section><h2>Forbundne enheder</h2><div id="devices"></div></section>
|
||||||
|
</main><script type="module" src="/static/js/remote-manage.js"></script></body></html>
|
||||||
31
app/modules/remote/mobile.html
Normal file
31
app/modules/remote/mobile.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#f3f6f6"><meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<title>Hub · Fjernbetjening</title><link rel="manifest" href="/remote/manifest.webmanifest"><link rel="stylesheet" href="/static/css/remote.css?v=20261014-day"></head>
|
||||||
|
<body class="remote-mobile"><svg class="icon-defs" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs><symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7v5l3 2"/></symbol><symbol id="i-cases" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="15" rx="3"/><path d="M9 5V3h6v2M8 10h8M8 14h5"/></symbol><symbol id="i-phone" viewBox="0 0 24 24"><path d="M7 3H4a1 1 0 0 0-1 1c0 9.4 7.6 17 17 17a1 1 0 0 0 1-1v-3l-5-2-2 2a13 13 0 0 1-7-7l2-2-2-5Z"/></symbol><symbol id="i-message" viewBox="0 0 24 24"><path d="M20 11a8 8 0 0 1-8 8H4l1.5-4A8 8 0 1 1 20 11Z"/><path d="M8 10h8M8 14h5"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-play" viewBox="0 0 24 24"><path d="m9 5 11 7-11 7V5Z"/></symbol><symbol id="i-pause" viewBox="0 0 24 24"><path d="M8 5v14M16 5v14"/></symbol><symbol id="i-stop" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2"/></symbol><symbol id="i-arrow" viewBox="0 0 24 24"><path d="M5 12h14m-5-5 5 5-5 5"/></symbol></defs></svg><main><header class="app-header"><div class="brand"><span class="brand-mark" aria-hidden="true">H<span>•</span></span><div><span class="eyebrow">BMC HUB</span><h1>Hub Remote<span class="brand-subtitle">Din arbejdsdag. Lige ved hånden.</span></h1></div></div><span id="connection" role="status">Forbinder…</span></header>
|
||||||
|
<p id="notice" role="alert"></p>
|
||||||
|
<div id="uncertain" hidden><p>Svaret på den sidste handling mangler. Kontrollér resultatet, før du sender mere.</p><button id="check-command">Kontrollér resultat</button><button id="retry-command">Prøv samme handling igen</button></div>
|
||||||
|
<section id="login" hidden><div class="login-emblem"><svg class="icon" aria-hidden="true"><use href="#i-clock"/></svg></div><span class="eyebrow">DIN HUB. LIGE VED HÅNDEN.</span><h2 id="login-title">Forbind din enhed</h2><p class="login-intro">Alt det vigtige fra Hub. Klar på din mobil.</p><form id="login-form">
|
||||||
|
<label id="code-label">12-cifret kode<input id="code" inputmode="numeric" autocomplete="off" pattern="[0-9]{12}" maxlength="12"></label>
|
||||||
|
<label>Din 4-cifrede PIN<input id="pin" type="password" inputmode="numeric" autocomplete="off" pattern="[0-9]{4}" maxlength="4" required></label>
|
||||||
|
<label id="device-label">Enhedens navn<input id="device" maxlength="100" value="Mobil / tablet"></label>
|
||||||
|
<button type="submit" id="login-submit">Forbind</button></form></section>
|
||||||
|
<div id="controls" hidden><div class="workspace-heading"><div><span class="eyebrow">DIT ARBEJDSRUM</span><h2>Min dag</h2></div><span id="remote-date"></span></div>
|
||||||
|
<section class="day-card" id="day-panel"><div class="section-heading"><span class="section-icon"><svg class="icon" aria-hidden="true"><use href="#i-cases"/></svg></span><h2>Min dag · planlagt</h2></div><div id="day-items">Henter dagens plan…</div><p id="day-status" role="status"></p><button type="button" id="screen-awake" class="secondary">Hold skærmen tændt</button><small id="screen-awake-status" role="status"></small></section>
|
||||||
|
<section class="hero" id="timer-panel"><div class="section-top"><span class="eyebrow">AKTIV SAG</span><span id="desktop">Computer ikke forbundet</span></div><h2 id="active-case">Vælg en sag</h2><p id="deadline">Ingen deadline</p>
|
||||||
|
<div class="timer-dial"><div class="timer-face"><span class="timer-caption"><svg class="icon" aria-hidden="true"><use href="#i-clock"/></svg> DIN TIMER</span><div id="elapsed" class="clock">00:00:00</div><p id="timer-case">Ingen timer kører</p></div></div>
|
||||||
|
<div class="buttons"><button data-action="start"><span class="control-icon"><svg class="icon" aria-hidden="true"><use href="#i-play"/></svg></span>Start timer</button><button data-action="pause"><span class="control-icon"><svg class="icon" aria-hidden="true"><use href="#i-pause"/></svg></span>Pause</button><button data-action="stop"><span class="control-icon"><svg class="icon" aria-hidden="true"><use href="#i-stop"/></svg></span>Stop og gem tid</button></div>
|
||||||
|
<label>Beskrivelse<textarea id="description" maxlength="2000" rows="2" placeholder="Hvad har du lavet?"></textarea></label>
|
||||||
|
<div id="paused"></div></section>
|
||||||
|
<section class="manual-card"><div class="section-heading"><span class="section-icon"><svg class="icon" aria-hidden="true"><use href="#i-plus"/></svg></span><h2>Registrer tid</h2></div><form id="manual-form"><label>Minutter på den valgte sag<input id="minutes" type="number" min="1" max="1440" required></label><button type="submit">Gem tid med beskrivelsen ovenfor</button></form></section>
|
||||||
|
<section class="cases-card" id="cases-panel"><div class="section-heading"><span class="section-icon"><svg class="icon" aria-hidden="true"><use href="#i-cases"/></svg></span><h2>Sager</h2><span class="section-hint">Find din næste opgave</span></div><nav id="case-tabs"><button data-kind="new" aria-pressed="true">Nye</button><button data-kind="own" aria-pressed="false">Mine</button><button data-kind="search" aria-pressed="false">Søg</button></nav>
|
||||||
|
<form id="search-form"><label>Sagsnummer, titel eller kunde<input id="search" type="search" maxlength="100" placeholder="Søg efter sag eller kunde…"></label><button>Søg</button></form><div id="cases"></div><button id="more" hidden>Vis flere</button></section>
|
||||||
|
<section class="today-card"><div><span class="eyebrow"><svg class="icon" aria-hidden="true"><use href="#i-clock"/></svg> DAGENS OVERBLIK</span><h2>Registreret i dag</h2></div><p id="today"></p></section>
|
||||||
|
<section class="phone-card" id="phone-card" hidden><div class="section-heading"><span class="section-icon"><svg class="icon" aria-hidden="true"><use href="#i-phone"/></svg></span><h2>Kontakter & opkald</h2><span id="call-count" class="count-badge" hidden></span></div>
|
||||||
|
<p class="section-hint">Ring via din VoIP-telefon i Hub</p><div class="call-heading"><span class="live-dot"></span><h3>Indgående opkald</h3><span class="section-hint">Live fra Hub</span></div><div id="incoming-calls" role="status">Henter opkald…</div>
|
||||||
|
<form id="contact-search-form"><label>Navn, firma, telefon eller e-mail<input id="contact-search" type="search" maxlength="100" placeholder="Find en kontakt…"></label><button>Søg kontakter</button></form><div id="contact-results"></div><button id="contact-more" hidden>Flere kontakter</button>
|
||||||
|
<p id="phone-notice" role="status"></p><button id="check-call" hidden>Kontrollér opkaldets status</button><button id="clear-call" hidden>Jeg har kontrolleret telefonen</button></section>
|
||||||
|
<section class="messages-card" id="messages-panel"><div class="section-heading"><span class="section-icon"><svg class="icon" aria-hidden="true"><use href="#i-message"/></svg></span><h2>Hub-beskeder</h2><span id="message-count" class="count-badge" hidden></span></div><div id="messages"></div><button data-action="read">Markér almindelige beskeder som læst</button></section>
|
||||||
|
<nav class="remote-nav" aria-label="Fjernbetjeningens navigation"><a href="#day-panel" aria-current="location"><svg class="icon" aria-hidden="true"><use href="#i-cases"/></svg><span>Min dag</span></a><a href="#timer-panel"><svg class="icon" aria-hidden="true"><use href="#i-clock"/></svg><span>Timer</span></a><a href="#cases-panel"><svg class="icon" aria-hidden="true"><use href="#i-cases"/></svg><span>Sager</span></a><a href="#phone-card"><svg class="icon" aria-hidden="true"><use href="#i-phone"/></svg><span>Kontakter</span></a><a href="#messages-panel"><svg class="icon" aria-hidden="true"><use href="#i-message"/></svg><span>Beskeder</span></a></nav><footer class="remote-footer"><span>BMC Hub · Fjernbetjening</span><button id="logout" class="secondary">Afbryd denne enhed</button></footer></div>
|
||||||
|
<dialog id="switch-dialog"><h2>Skal timeren også skifte?</h2><p>Den nuværende timer pauses, hvis du skifter timer.</p><div class="buttons"><button data-switch="view">Kun skift sag</button><button data-switch="timer">Skift også timer</button><button data-switch="cancel" class="secondary">Annuller</button></div></dialog>
|
||||||
|
</main><script type="module" src="/static/js/remote-mobile.js?v=20261014-day"></script></body></html>
|
||||||
298
app/modules/remote/router.py
Normal file
298
app/modules/remote/router.py
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
"""Remote API and mobile/administration pages in the existing Hub."""
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Query
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
from app.core.auth_dependencies import get_current_user
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
from app.core.database import atomic_transaction, execute_query, execute_query_single
|
||||||
|
from . import service
|
||||||
|
from .security import COOKIE, CSRF_COOKIE, guard, today, digest, lookup, new_code, reserve_attempt, remote_device, actor
|
||||||
|
|
||||||
|
router = APIRouter(prefix='/api/v1/remote', dependencies=[Depends(guard)])
|
||||||
|
views = APIRouter(dependencies=[Depends(guard)])
|
||||||
|
ROOT = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
class Credentials(BaseModel):
|
||||||
|
code: str = Field(default='', max_length=200)
|
||||||
|
pin: str = Field(max_length=200)
|
||||||
|
name: str = Field(default='Mobil / tablet', min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class Pin(BaseModel):
|
||||||
|
pin: str = Field(pattern=r'^[0-9]{4}$')
|
||||||
|
|
||||||
|
|
||||||
|
class Name(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseModel):
|
||||||
|
model_config = ConfigDict(extra='forbid')
|
||||||
|
request_id: UUID
|
||||||
|
version: int = Field(ge=0)
|
||||||
|
timer_version: str = Field(pattern=r'^[0-9a-f]{64}$')
|
||||||
|
action: Literal['select', 'start', 'pause', 'resume', 'stop', 'manual', 'read', 'ack']
|
||||||
|
case_id: int | None = Field(default=None, gt=0)
|
||||||
|
time_id: int | None = Field(default=None, gt=0)
|
||||||
|
description: str = Field(default='', max_length=2000)
|
||||||
|
minutes: int | None = Field(default=None, ge=1, le=1440)
|
||||||
|
switch_timer: bool | None = None
|
||||||
|
partner_user_id: int | None = Field(default=None, gt=0)
|
||||||
|
message_id: int | None = Field(default=None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class Follow(BaseModel):
|
||||||
|
tab_id: UUID
|
||||||
|
enable: bool = False
|
||||||
|
disable: bool = False
|
||||||
|
after: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneCall(BaseModel):
|
||||||
|
model_config = ConfigDict(extra='forbid')
|
||||||
|
request_id: UUID
|
||||||
|
contact_id: int = Field(gt=0)
|
||||||
|
field: Literal['phone', 'mobile']
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/contacts')
|
||||||
|
async def remote_contacts(request: Request, q: str = Query('', max_length=100), offset: int = Query(0, ge=0)):
|
||||||
|
from . import telephony
|
||||||
|
return await telephony.contacts(actor(remote_device(request)), q, offset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/day')
|
||||||
|
def remote_day(request: Request):
|
||||||
|
return service.planned_day(actor(remote_device(request)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/telephony')
|
||||||
|
def remote_telephony(request: Request):
|
||||||
|
from . import telephony
|
||||||
|
return telephony.status(actor(remote_device(request)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/call/{request_id}')
|
||||||
|
def remote_call_result(request_id: UUID, request: Request):
|
||||||
|
from . import telephony
|
||||||
|
user = actor(remote_device(request)); telephony.require_access(user)
|
||||||
|
row = execute_query_single('SELECT payload,result FROM remote_commands WHERE user_id=%s AND request_id=%s', (user['id'], str(request_id)))
|
||||||
|
if not row or row['payload'].get('action') != 'call':
|
||||||
|
return {'status': 'unknown', 'message': 'Hub har ikke registreret opkaldet. Kontrollér telefonen før et nyt opkald.'}
|
||||||
|
return row['result']
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/call')
|
||||||
|
async def remote_call(payload: PhoneCall, request: Request):
|
||||||
|
from . import telephony
|
||||||
|
return await telephony.call(payload, request)
|
||||||
|
|
||||||
|
|
||||||
|
def authenticated(request: Request):
|
||||||
|
return actor(remote_device(request))
|
||||||
|
|
||||||
|
|
||||||
|
def lock_user(uid):
|
||||||
|
execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (uid,))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/bootstrap')
|
||||||
|
def bootstrap(request: Request, response: Response):
|
||||||
|
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(32)
|
||||||
|
response.set_cookie(CSRF_COOKIE, csrf, secure=request.url.scheme == 'https', httponly=True, samesite='strict', path='/', max_age=30*86400)
|
||||||
|
status = 'connect'
|
||||||
|
try:
|
||||||
|
device = remote_device(request, unlocked=False)
|
||||||
|
status = 'ready' if device['unlocked_date'] == today() and device['pin_generation'] == device['generation'] else 'locked'
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
return {'csrf': csrf, 'status': status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/connect')
|
||||||
|
def connect(payload: Credentials, request: Request, response: Response):
|
||||||
|
remote = execute_query_single('SELECT * FROM remote_controls WHERE code_lookup=%s', (lookup(payload.code),))
|
||||||
|
reserve_attempt(request.client.host if request.client else 'unknown', (remote or {}).get('user_id'))
|
||||||
|
if not re.fullmatch(r'[0-9]{12}', payload.code) or not re.fullmatch(r'[0-9]{4}', payload.pin) or not remote:
|
||||||
|
raise HTTPException(401, 'Forkert kode eller PIN')
|
||||||
|
with atomic_transaction():
|
||||||
|
lock_user(remote['user_id'])
|
||||||
|
remote = execute_query_single('SELECT r.*,u.is_active FROM remote_controls r JOIN users u ON u.user_id=r.user_id WHERE r.id=%s AND NOT r.revoked', (str(remote['id']),))
|
||||||
|
pin = execute_query_single('SELECT * FROM remote_pins WHERE user_id=%s', (remote['user_id'],)) if remote else None
|
||||||
|
if not remote or not remote['is_active'] or not pin or not AuthService.verify_password(payload.code, remote['code_hash']) or not AuthService.verify_password(lookup('pin:' + payload.pin), pin['pin_hash']):
|
||||||
|
raise HTTPException(401, 'Forkert kode eller PIN')
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
execute_query('''INSERT INTO remote_devices(id,remote_id,name,token_hash,expires_at,unlocked_date,pin_generation)
|
||||||
|
VALUES(%s,%s,%s,%s,NOW()+INTERVAL '30 days',%s,%s)''',
|
||||||
|
(str(uuid4()), str(remote['id']), payload.name.strip() or 'Mobil / tablet', digest(token), today(), pin['generation']))
|
||||||
|
response.set_cookie(COOKIE, token, secure=request.url.scheme == 'https', httponly=True, samesite='strict', path='/api/v1/remote', max_age=30*86400)
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/unlock')
|
||||||
|
def unlock(payload: Credentials, request: Request):
|
||||||
|
device = remote_device(request, unlocked=False)
|
||||||
|
reserve_attempt(request.client.host if request.client else 'unknown', device['user_id'], device['id'])
|
||||||
|
with atomic_transaction():
|
||||||
|
lock_user(device['user_id'])
|
||||||
|
device = remote_device(request, unlocked=False)
|
||||||
|
if not re.fullmatch(r'[0-9]{4}', payload.pin) or not AuthService.verify_password(lookup('pin:' + payload.pin), device['pin_hash']):
|
||||||
|
raise HTTPException(401, 'Forkert PIN')
|
||||||
|
execute_query('UPDATE remote_devices SET unlocked_date=%s,pin_generation=%s,last_seen_at=NOW() WHERE id=%s', (today(), device['generation'], str(device['id'])))
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/logout')
|
||||||
|
def logout(request: Request, response: Response):
|
||||||
|
device = remote_device(request, unlocked=False)
|
||||||
|
with atomic_transaction():
|
||||||
|
lock_user(device['user_id'])
|
||||||
|
execute_query('UPDATE remote_devices SET revoked=TRUE WHERE id=%s', (str(device['id']),))
|
||||||
|
response.delete_cookie(COOKIE, path='/api/v1/remote', secure=request.url.scheme == 'https', httponly=True, samesite='strict')
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/state')
|
||||||
|
def get_state(request: Request):
|
||||||
|
device = remote_device(request)
|
||||||
|
with atomic_transaction():
|
||||||
|
lock_user(device['user_id'])
|
||||||
|
device = remote_device(request)
|
||||||
|
execute_query('UPDATE remote_devices SET last_seen_at=NOW() WHERE id=%s', (str(device['id']),))
|
||||||
|
return service.state(actor(device))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/cases')
|
||||||
|
def list_cases(kind: Literal['new', 'own', 'search'] = 'search', q: str = Query('', max_length=100),
|
||||||
|
offset: int = Query(0, ge=0), user=Depends(authenticated)):
|
||||||
|
return service.cases(user, kind, q, offset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/command')
|
||||||
|
async def run_command(payload: Command, request: Request, user=Depends(authenticated)):
|
||||||
|
return await service.command(payload, user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/command-result/{request_id}')
|
||||||
|
def command_result(request_id: UUID, user=Depends(authenticated)):
|
||||||
|
result = execute_query_single('SELECT result FROM remote_commands WHERE user_id=%s AND request_id=%s', (user['id'], str(request_id)))
|
||||||
|
return {'completed': bool(result), 'result': result['result'] if result else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/admin')
|
||||||
|
def admin(user=Depends(get_current_user)):
|
||||||
|
uid = user['id']
|
||||||
|
remotes = execute_query('SELECT id,name,revoked,created_at FROM remote_controls WHERE user_id=%s ORDER BY created_at DESC', (uid,))
|
||||||
|
devices = execute_query('''SELECT d.id,d.remote_id,d.name,d.expires_at,d.revoked,d.last_seen_at FROM remote_devices d
|
||||||
|
JOIN remote_controls r ON r.id=d.remote_id WHERE r.user_id=%s ORDER BY d.last_seen_at DESC''', (uid,))
|
||||||
|
return {'remotes': remotes, 'devices': devices, 'pin_set': bool(execute_query_single('SELECT user_id FROM remote_pins WHERE user_id=%s', (uid,)))}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put('/admin/pin')
|
||||||
|
def set_pin(payload: Pin, user=Depends(get_current_user)):
|
||||||
|
hashed = AuthService.hash_password(lookup('pin:' + payload.pin))
|
||||||
|
with atomic_transaction():
|
||||||
|
lock_user(user['id'])
|
||||||
|
execute_query('''INSERT INTO remote_pins(user_id,pin_hash) VALUES(%s,%s)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET pin_hash=EXCLUDED.pin_hash,generation=remote_pins.generation+1''', (user['id'], hashed))
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
def create_code(uid, name, remote_id=None):
|
||||||
|
with atomic_transaction():
|
||||||
|
lock_user(uid)
|
||||||
|
if remote_id and not execute_query_single('SELECT id FROM remote_controls WHERE id=%s AND user_id=%s AND NOT revoked', (str(remote_id), uid)):
|
||||||
|
raise HTTPException(404, 'Fjernbetjeningen findes ikke')
|
||||||
|
if not execute_query_single('SELECT user_id FROM remote_pins WHERE user_id=%s', (uid,)):
|
||||||
|
raise HTTPException(409, 'Opret din PIN først')
|
||||||
|
# A global advisory lock prevents even extremely unlikely code collisions.
|
||||||
|
execute_query('SELECT pg_advisory_xact_lock(1069,1)')
|
||||||
|
code = new_code()
|
||||||
|
while execute_query_single('SELECT id FROM remote_controls WHERE code_lookup=%s', (lookup(code),)):
|
||||||
|
code = new_code()
|
||||||
|
if remote_id:
|
||||||
|
execute_query('UPDATE remote_controls SET code_lookup=%s,code_hash=%s WHERE id=%s', (lookup(code), AuthService.hash_password(code), str(remote_id)))
|
||||||
|
execute_query('UPDATE remote_devices SET revoked=TRUE WHERE remote_id=%s', (str(remote_id),))
|
||||||
|
else:
|
||||||
|
remote_id = uuid4()
|
||||||
|
execute_query('INSERT INTO remote_controls(id,user_id,name,code_lookup,code_hash) VALUES(%s,%s,%s,%s,%s)', (str(remote_id), uid, name, lookup(code), AuthService.hash_password(code)))
|
||||||
|
return {'id': str(remote_id), 'code': code}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/admin/remotes')
|
||||||
|
def create_remote(payload: Name, user=Depends(get_current_user)):
|
||||||
|
return create_code(user['id'], payload.name.strip() or 'Fjernbetjening')
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/admin/remotes/{remote_id}/rotate')
|
||||||
|
def rotate_remote(remote_id: UUID, user=Depends(get_current_user)):
|
||||||
|
return create_code(user['id'], '', remote_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete('/admin/remotes/{remote_id}')
|
||||||
|
def revoke_remote(remote_id: UUID, user=Depends(get_current_user)):
|
||||||
|
with atomic_transaction():
|
||||||
|
lock_user(user['id'])
|
||||||
|
rows = execute_query('UPDATE remote_controls SET revoked=TRUE WHERE id=%s AND user_id=%s RETURNING id', (str(remote_id), user['id']))
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(404, 'Fjernbetjeningen findes ikke')
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete('/admin/devices/{device_id}')
|
||||||
|
def revoke_device(device_id: UUID, user=Depends(get_current_user)):
|
||||||
|
with atomic_transaction():
|
||||||
|
lock_user(user['id'])
|
||||||
|
rows = execute_query('''UPDATE remote_devices d SET revoked=TRUE FROM remote_controls r
|
||||||
|
WHERE d.remote_id=r.id AND d.id=%s AND r.user_id=%s RETURNING d.id''', (str(device_id), user['id']))
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(404, 'Enheden findes ikke')
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/follow')
|
||||||
|
def follow(payload: Follow, user=Depends(get_current_user)):
|
||||||
|
with atomic_transaction():
|
||||||
|
lock_user(user['id'])
|
||||||
|
s = service.user_state(user['id'])
|
||||||
|
if payload.enable:
|
||||||
|
execute_query('''UPDATE remote_user_state SET follower_id=%s,follower_seen_at=NOW(),
|
||||||
|
selection_target=NULL,selection_expires_at=NULL WHERE user_id=%s''', (str(payload.tab_id), user['id']))
|
||||||
|
return {'following': True, 'sequence': s['selection_seq']}
|
||||||
|
if str(s['follower_id']) != str(payload.tab_id):
|
||||||
|
return {'following': False, 'sequence': s['selection_seq']}
|
||||||
|
if payload.disable:
|
||||||
|
execute_query('UPDATE remote_user_state SET follower_id=NULL,follower_seen_at=NULL,selection_target=NULL WHERE user_id=%s', (user['id'],))
|
||||||
|
return {'following': False, 'sequence': s['selection_seq']}
|
||||||
|
# A missed heartbeat must not deliver a historical selection on reconnect.
|
||||||
|
connected = bool(s['connected'])
|
||||||
|
execute_query('UPDATE remote_user_state SET follower_seen_at=NOW() WHERE user_id=%s', (user['id'],))
|
||||||
|
event = None
|
||||||
|
if connected and str(s['selection_target']) == str(payload.tab_id) and s['selection_seq'] > payload.after and s['selection_expires_at'] and s['selection_expires_at'] > service.datetime.now(service.timezone.utc):
|
||||||
|
service.case(s['active_case_id'], user)
|
||||||
|
event = {'case_id': s['active_case_id']}
|
||||||
|
return {'following': True, 'sequence': s['selection_seq'], 'selection': event}
|
||||||
|
|
||||||
|
|
||||||
|
@views.get('/remote', response_class=HTMLResponse)
|
||||||
|
def remote_page():
|
||||||
|
return HTMLResponse((ROOT / 'mobile.html').read_text(), headers={'Cache-Control': 'no-store'})
|
||||||
|
|
||||||
|
|
||||||
|
@views.get('/remote/manage', response_class=HTMLResponse)
|
||||||
|
def manage_page(user=Depends(get_current_user)):
|
||||||
|
return HTMLResponse((ROOT / 'manage.html').read_text(), headers={'Cache-Control': 'no-store'})
|
||||||
|
|
||||||
|
|
||||||
|
@views.get('/remote/manifest.webmanifest')
|
||||||
|
def manifest():
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse({'name': 'BMC Hub Fjernbetjening', 'short_name': 'Hub Remote', 'start_url': '/remote',
|
||||||
|
'display': 'standalone', 'theme_color': '#0f4c75', 'background_color': '#f4f6f8'}, media_type='application/manifest+json')
|
||||||
92
app/modules/remote/security.py
Normal file
92
app/modules/remote/security.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""Remote credentials are deliberately separate from normal Hub authentication."""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import math
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request, Response
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
from app.core.database import atomic_transaction, execute_query, execute_query_single
|
||||||
|
|
||||||
|
COOKIE = 'hub_remote_device'
|
||||||
|
CSRF_COOKIE = 'hub_remote_csrf'
|
||||||
|
|
||||||
|
|
||||||
|
def today(now=None):
|
||||||
|
return (now or datetime.now(timezone.utc)).astimezone(ZoneInfo('Europe/Copenhagen')).date()
|
||||||
|
|
||||||
|
|
||||||
|
def digest(value):
|
||||||
|
return hashlib.sha256(value.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def lookup(value):
|
||||||
|
return hmac.new(settings.REMOTE_LOOKUP_KEY.encode(), value.encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def new_code():
|
||||||
|
return f'{secrets.randbelow(10**12):012d}'
|
||||||
|
|
||||||
|
|
||||||
|
def guard(request: Request, response: Response):
|
||||||
|
response.headers['Cache-Control'] = 'no-store'
|
||||||
|
if not settings.REMOTE_ENABLED:
|
||||||
|
raise HTTPException(404, 'Fjernbetjening er slået fra')
|
||||||
|
if len(settings.REMOTE_LOOKUP_KEY) < 32:
|
||||||
|
raise HTTPException(503, 'Fjernbetjening mangler sikker serverkonfiguration')
|
||||||
|
if request.method not in {'GET', 'HEAD', 'OPTIONS'}:
|
||||||
|
origin = str(request.base_url).rstrip('/')
|
||||||
|
if request.headers.get('origin') != origin:
|
||||||
|
raise HTTPException(403, 'Ugyldig oprindelse')
|
||||||
|
cookie = request.cookies.get(CSRF_COOKIE, '')
|
||||||
|
token = request.headers.get('x-remote-csrf', '')
|
||||||
|
if not cookie or not hmac.compare_digest(cookie, token):
|
||||||
|
raise HTTPException(403, 'Opdatér siden og prøv igen')
|
||||||
|
|
||||||
|
|
||||||
|
def reserve_attempt(ip, user_id=None, device_id=None):
|
||||||
|
"""Commit even failed attempts, and serialize all buckets across worker processes."""
|
||||||
|
buckets = sorted({lookup('ip:' + ip), *([lookup('user:' + str(user_id))] if user_id else []),
|
||||||
|
*([lookup('device:' + str(device_id))] if device_id else [])})
|
||||||
|
blocked = None
|
||||||
|
with atomic_transaction():
|
||||||
|
for bucket in buckets:
|
||||||
|
execute_query("INSERT INTO remote_attempts(bucket,next_allowed_at) VALUES(%s,'epoch') ON CONFLICT DO NOTHING", (bucket,))
|
||||||
|
rows = execute_query('SELECT next_allowed_at FROM remote_attempts WHERE bucket=ANY(%s) ORDER BY bucket FOR UPDATE', (buckets,))
|
||||||
|
now = execute_query_single('SELECT clock_timestamp() AS now')['now']
|
||||||
|
wait = max([(row['next_allowed_at'] - now).total_seconds() for row in rows] + [0])
|
||||||
|
if wait > 0:
|
||||||
|
blocked = max(1, math.ceil(wait))
|
||||||
|
else:
|
||||||
|
execute_query("UPDATE remote_attempts SET next_allowed_at=clock_timestamp()+INTERVAL '30 seconds' WHERE bucket=ANY(%s)", (buckets,))
|
||||||
|
if blocked:
|
||||||
|
raise HTTPException(429, 'Vent før næste loginforsøg', headers={'Retry-After': str(blocked)})
|
||||||
|
|
||||||
|
|
||||||
|
def remote_device(request, unlocked=True):
|
||||||
|
token = request.cookies.get(COOKIE)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(401, 'Forbind enheden med din kode')
|
||||||
|
row = execute_query_single('''SELECT d.*,r.user_id,p.generation,p.pin_hash,u.username,u.full_name,u.is_superadmin
|
||||||
|
FROM remote_devices d JOIN remote_controls r ON r.id=d.remote_id
|
||||||
|
JOIN remote_pins p ON p.user_id=r.user_id JOIN users u ON u.user_id=r.user_id
|
||||||
|
WHERE d.token_hash=%s AND NOT d.revoked AND NOT r.revoked AND d.expires_at>NOW() AND u.is_active=TRUE''', (digest(token),))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(401, 'Forbindelsen er udløbet eller tilbagekaldt')
|
||||||
|
if unlocked and (row['unlocked_date'] != today() or row['pin_generation'] != row['generation']):
|
||||||
|
raise HTTPException(423, 'Indtast dagens PIN')
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def actor(device):
|
||||||
|
# Current permissions are fetched for every request; no cached admin JWT is minted.
|
||||||
|
return {'id': device['user_id'], 'username': device['username'], 'full_name': device['full_name'],
|
||||||
|
'is_superadmin': device['is_superadmin'], 'permissions': AuthService.get_user_permissions(device['user_id'])}
|
||||||
|
|
||||||
|
|
||||||
|
def case_permission(user):
|
||||||
|
if not user.get('is_superadmin') and not {'cases.view', 'tickets.view'}.intersection(user.get('permissions') or []):
|
||||||
|
raise HTTPException(403, 'Du har ikke adgang til sager')
|
||||||
195
app/modules/remote/service.py
Normal file
195
app/modules/remote/service.py
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
"""Restricted remote actions reuse existing time and messaging business logic."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from psycopg2.extras import Json
|
||||||
|
from app.core.database import atomic_transaction, execute_query, execute_query_single
|
||||||
|
from app.modules.bottom_bar.backend.service import get_own_timer_snapshot, get_user_messages_summary, mark_user_messages_read, acknowledge_message
|
||||||
|
from app.modules.sag.backend.create_support import closed_statuses
|
||||||
|
from .security import case_permission, today
|
||||||
|
|
||||||
|
|
||||||
|
def user_state(user_id):
|
||||||
|
execute_query('INSERT INTO remote_user_state(user_id) VALUES(%s) ON CONFLICT DO NOTHING', (user_id,))
|
||||||
|
return execute_query_single('SELECT *,follower_seen_at>NOW()-INTERVAL \'10 seconds\' AS connected FROM remote_user_state WHERE user_id=%s', (user_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def case(case_id, user):
|
||||||
|
case_permission(user)
|
||||||
|
row = execute_query_single('SELECT id,titel,status,deadline,customer_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL', (case_id,))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, 'Sagen findes ikke længere')
|
||||||
|
return case_display(row)
|
||||||
|
|
||||||
|
|
||||||
|
def case_display(row, now=None):
|
||||||
|
row = dict(row)
|
||||||
|
deadline = row.get('deadline')
|
||||||
|
if deadline:
|
||||||
|
# Existing case timestamps without offset use Hub's local Danish timezone.
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
if deadline.tzinfo is None:
|
||||||
|
deadline = deadline.replace(tzinfo=ZoneInfo('Europe/Copenhagen'))
|
||||||
|
seconds = int((deadline - (now or datetime.now(timezone.utc))).total_seconds())
|
||||||
|
row.update(deadline=deadline.isoformat(), deadline_seconds=seconds,
|
||||||
|
deadline_state='overdue' if seconds < 0 else 'warning' if seconds <= 3600 else 'normal')
|
||||||
|
else:
|
||||||
|
row.update(deadline_seconds=None, deadline_state='none')
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def cases(user, kind='search', q='', offset=0):
|
||||||
|
case_permission(user)
|
||||||
|
where = 's.deleted_at IS NULL'
|
||||||
|
params = []
|
||||||
|
if kind == 'new':
|
||||||
|
where += " AND LOWER(TRIM(s.status))='ny'"
|
||||||
|
elif kind == 'own':
|
||||||
|
where += ' AND s.ansvarlig_bruger_id=%s AND NOT (LOWER(TRIM(s.status))=ANY(%s))'
|
||||||
|
params.extend([user['id'], closed_statuses()])
|
||||||
|
if q:
|
||||||
|
where += ' AND (s.titel ILIKE %s OR s.id::text=%s OR c.name ILIKE %s)'
|
||||||
|
params.extend(['%' + q + '%', q, '%' + q + '%'])
|
||||||
|
rows = execute_query(f'''SELECT s.id,s.titel,s.status,s.deadline,c.name AS customer_name FROM sag_sager s
|
||||||
|
LEFT JOIN customers c ON c.id=s.customer_id WHERE {where}
|
||||||
|
ORDER BY s.deadline ASC NULLS LAST,s.created_at DESC,s.id DESC LIMIT 51 OFFSET %s''', tuple(params + [offset]))
|
||||||
|
return {'items': [case_display(row) for row in rows[:50]], 'has_more': len(rows) > 50}
|
||||||
|
|
||||||
|
|
||||||
|
def timer_version(user_id):
|
||||||
|
"""Fingerprint changes made by desktop as well as remote, excluding elapsed seconds."""
|
||||||
|
from .security import digest
|
||||||
|
rows = execute_query('''SELECT id,sag_id,aktiv_timer,start_tid,slut_tid,paused_at,pause_total_seconds,updated_at
|
||||||
|
FROM tmodule_times WHERE medarbejder_id=%s AND slut_tid IS NULL
|
||||||
|
AND (aktiv_timer=TRUE OR paused_at IS NOT NULL) ORDER BY id''', (user_id,))
|
||||||
|
latest = execute_query_single('''SELECT id,updated_at FROM tmodule_times WHERE medarbejder_id=%s
|
||||||
|
ORDER BY updated_at DESC NULLS LAST,id DESC LIMIT 1''', (user_id,))
|
||||||
|
import json
|
||||||
|
return digest(json.dumps(jsonable_encoder([rows, latest]), sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
def planned_day(user, day=None):
|
||||||
|
from datetime import timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
case_permission(user)
|
||||||
|
day = day or today()
|
||||||
|
zone = ZoneInfo('Europe/Copenhagen')
|
||||||
|
start = datetime.combine(day, datetime.min.time(), tzinfo=zone)
|
||||||
|
end = datetime.combine(day + timedelta(days=1), datetime.min.time(), tzinfo=zone)
|
||||||
|
rows = execute_query("""SELECT a.id, a.sag_id AS case_id, a.starts_at, a.ends_at,
|
||||||
|
s.titel AS title, c.name AS customer_name, 'case' AS kind
|
||||||
|
FROM planner_allocations a JOIN sag_sager s ON s.id=a.sag_id
|
||||||
|
LEFT JOIN customers c ON c.id=s.customer_id
|
||||||
|
WHERE a.user_id=%s AND a.deleted_at IS NULL AND s.deleted_at IS NULL
|
||||||
|
AND a.starts_at<%s AND a.ends_at>%s
|
||||||
|
UNION ALL
|
||||||
|
SELECT q.id, NULL AS case_id, q.starts_at, q.ends_at, q.description AS title,
|
||||||
|
COALESCE(c.name,'Intern tid') AS customer_name, 'standalone' AS kind
|
||||||
|
FROM quick_time_items q LEFT JOIN customers c ON c.id=q.customer_id
|
||||||
|
WHERE q.user_id=%s AND q.state='planned' AND q.starts_at<%s AND q.ends_at>%s
|
||||||
|
ORDER BY starts_at""", (user['id'], end, start, user['id'], end, start)) or []
|
||||||
|
return {'date': day.isoformat(), 'items': rows}
|
||||||
|
|
||||||
|
|
||||||
|
def state(user):
|
||||||
|
s = user_state(user['id'])
|
||||||
|
selected = None
|
||||||
|
if s['active_case_id']:
|
||||||
|
try:
|
||||||
|
selected = case(s['active_case_id'], user)
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code != 404:
|
||||||
|
raise
|
||||||
|
total = execute_query_single('''SELECT COALESCE(SUM(faktisk_tid_min),0)::int AS minutes
|
||||||
|
FROM tmodule_times WHERE medarbejder_id=%s AND worked_date=%s
|
||||||
|
AND NOT aktiv_timer AND paused_at IS NULL''', (user['id'], today()))
|
||||||
|
return {'version': s['version'], 'timer_version': timer_version(user['id']), 'active_case': selected,
|
||||||
|
'timer': get_own_timer_snapshot(user['id']), 'today_minutes': total['minutes'],
|
||||||
|
'desktop_connected': bool(s['connected']), 'messages': get_user_messages_summary(user['id']),
|
||||||
|
'server_time': datetime.now(timezone.utc).isoformat(), 'user_id': user['id'],
|
||||||
|
'user_name': user['full_name'] or user['username']}
|
||||||
|
|
||||||
|
|
||||||
|
async def command(payload, user, request=None):
|
||||||
|
"""Atomic idempotency + remote version + user lock shared with desktop timer writes."""
|
||||||
|
import importlib
|
||||||
|
timers = importlib.import_module('app.timetracking.backend.router')
|
||||||
|
uid = user['id']
|
||||||
|
value = payload.model_dump(mode='json')
|
||||||
|
with atomic_transaction():
|
||||||
|
execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (uid,))
|
||||||
|
if request is not None:
|
||||||
|
from .security import remote_device, actor
|
||||||
|
user = actor(remote_device(request))
|
||||||
|
s = user_state(uid)
|
||||||
|
old = execute_query_single('SELECT payload,result FROM remote_commands WHERE user_id=%s AND request_id=%s', (uid, str(payload.request_id)))
|
||||||
|
if old:
|
||||||
|
if old['payload'] != value:
|
||||||
|
raise HTTPException(409, 'Handlingen har allerede andre oplysninger')
|
||||||
|
return old['result']
|
||||||
|
if payload.version != s['version'] or payload.timer_version != timer_version(uid):
|
||||||
|
raise HTTPException(409, 'Hub er ændret. Opdatér og prøv igen')
|
||||||
|
action = payload.action
|
||||||
|
result = {'ok': True}
|
||||||
|
if action in {'select', 'start', 'manual'}:
|
||||||
|
if not payload.case_id:
|
||||||
|
raise HTTPException(400, 'Vælg en sag')
|
||||||
|
case(payload.case_id, user)
|
||||||
|
timer_data = {'sag_id': payload.case_id, 'beskrivelse': payload.description,
|
||||||
|
'billing_mode': 'automatic'}
|
||||||
|
if action == 'select':
|
||||||
|
active = get_own_timer_snapshot(uid)['active']
|
||||||
|
if active.get('active') and active.get('sag_id') != payload.case_id:
|
||||||
|
if payload.switch_timer is None:
|
||||||
|
raise HTTPException(409, 'Vælg om timeren også skal skifte')
|
||||||
|
if payload.switch_timer:
|
||||||
|
await timers.pause_live_timer_v1(current_user=user)
|
||||||
|
paused = execute_query_single('''SELECT id FROM tmodule_times WHERE medarbejder_id=%s
|
||||||
|
AND sag_id=%s AND NOT aktiv_timer AND paused_at IS NOT NULL AND slut_tid IS NULL
|
||||||
|
ORDER BY paused_at DESC LIMIT 1''', (uid, payload.case_id))
|
||||||
|
if paused:
|
||||||
|
await timers.resume_live_timer_v1(payload={'time_id': paused['id']}, current_user=user)
|
||||||
|
else:
|
||||||
|
await timers.start_live_timer_v1(payload=timer_data, current_user=user)
|
||||||
|
execute_query('''UPDATE remote_user_state SET active_case_id=%s,selection_seq=selection_seq+1,
|
||||||
|
selection_target=CASE WHEN follower_seen_at>NOW()-INTERVAL '10 seconds' THEN follower_id ELSE NULL END,
|
||||||
|
selection_expires_at=NOW()+INTERVAL '10 seconds' WHERE user_id=%s''', (payload.case_id, uid))
|
||||||
|
elif action == 'start':
|
||||||
|
active = get_own_timer_snapshot(uid)['active']
|
||||||
|
if active.get('active'):
|
||||||
|
raise HTTPException(409, 'En timer kører allerede. Brug sagsvalg for at skifte')
|
||||||
|
result = await timers.start_live_timer_v1(payload=timer_data, current_user=user)
|
||||||
|
elif action in {'pause', 'resume', 'stop'}:
|
||||||
|
if payload.time_id:
|
||||||
|
owned = execute_query_single('''SELECT id FROM tmodule_times WHERE id=%s AND medarbejder_id=%s
|
||||||
|
AND slut_tid IS NULL AND start_tid IS NOT NULL AND (aktiv_timer OR paused_at IS NOT NULL)''', (payload.time_id, uid))
|
||||||
|
if not owned:
|
||||||
|
raise HTTPException(404, 'Timeren findes ikke eller er afsluttet')
|
||||||
|
if action == 'pause':
|
||||||
|
result = await timers.pause_live_timer_v1(current_user=user)
|
||||||
|
elif action == 'resume':
|
||||||
|
result = await timers.resume_live_timer_v1(payload={'time_id': payload.time_id}, current_user=user)
|
||||||
|
else:
|
||||||
|
if not payload.description.strip():
|
||||||
|
raise HTTPException(400, 'Angiv beskrivelse før stop')
|
||||||
|
active = get_own_timer_snapshot(uid)['active']
|
||||||
|
tid = payload.time_id or active.get('time_entry_id')
|
||||||
|
if not tid:
|
||||||
|
raise HTTPException(404, 'Ingen timer valgt')
|
||||||
|
execute_query('UPDATE tmodule_times SET description=%s WHERE id=%s AND medarbejder_id=%s AND slut_tid IS NULL', (payload.description, tid, uid))
|
||||||
|
result = await timers.stop_live_timer_v1(payload={'time_id': tid}, current_user=user)
|
||||||
|
elif action == 'manual':
|
||||||
|
if not payload.description.strip() or not payload.minutes:
|
||||||
|
raise HTTPException(400, 'Angiv minutter og beskrivelse')
|
||||||
|
timer_data.update(faktisk_tid_min=payload.minutes, worked_date=today().isoformat())
|
||||||
|
result = await timers.create_manual_time_v1(payload=timer_data, current_user=user)
|
||||||
|
elif action == 'read':
|
||||||
|
result = {'updated': mark_user_messages_read(uid, partner_user_id=payload.partner_user_id)}
|
||||||
|
elif action == 'ack':
|
||||||
|
if not payload.message_id or not acknowledge_message(uid, payload.message_id):
|
||||||
|
raise HTTPException(404, 'Beskeden findes ikke eller er allerede bekræftet')
|
||||||
|
execute_query('UPDATE remote_user_state SET version=version+1 WHERE user_id=%s', (uid,))
|
||||||
|
result = jsonable_encoder({'ok': True, 'result': result, 'version': s['version'] + 1})
|
||||||
|
execute_query('INSERT INTO remote_commands(user_id,request_id,payload,result) VALUES(%s,%s,%s,%s)', (uid, str(payload.request_id), Json(value), Json(result)))
|
||||||
|
return result
|
||||||
87
app/modules/remote/telephony.py
Normal file
87
app/modules/remote/telephony.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"""Contact lookup and restricted control of the owner's existing VoIP phone."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from psycopg2.extras import Json
|
||||||
|
from app.core.database import atomic_transaction, execute_query, execute_query_single
|
||||||
|
from app.dashboard.backend.mission_service import MissionService
|
||||||
|
from .security import actor, remote_device
|
||||||
|
|
||||||
|
|
||||||
|
def permitted(user):
|
||||||
|
return bool(user.get('is_superadmin') or 'customers.view' in (user.get('permissions') or []))
|
||||||
|
|
||||||
|
|
||||||
|
def require_access(user):
|
||||||
|
if not permitted(user):
|
||||||
|
raise HTTPException(403, 'Du har ikke adgang til kontakter og telefoni')
|
||||||
|
|
||||||
|
|
||||||
|
async def contacts(user, q, offset):
|
||||||
|
require_access(user)
|
||||||
|
from app.contacts.backend.router_simple import get_contacts
|
||||||
|
data = await get_contacts(search=q, customer_id=None, is_active=True, limit=50, offset=offset)
|
||||||
|
fields = ('id', 'first_name', 'last_name', 'email', 'phone', 'mobile', 'company_names')
|
||||||
|
return {'items': [{key: row.get(key) for key in fields} for row in data['contacts']],
|
||||||
|
'has_more': offset + len(data['contacts']) < data['total']}
|
||||||
|
|
||||||
|
|
||||||
|
def status(user):
|
||||||
|
if not permitted(user):
|
||||||
|
return {'allowed': False, 'incoming': []}
|
||||||
|
# Match Mission Control's ring window without changing shared call state.
|
||||||
|
rows = MissionService.get_active_calls()
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
incoming = []
|
||||||
|
for row in rows:
|
||||||
|
updated = row.get('updated_at') or row.get('started_at')
|
||||||
|
if not updated:
|
||||||
|
continue
|
||||||
|
if updated.tzinfo is None:
|
||||||
|
updated = updated.replace(tzinfo=ZoneInfo('Europe/Copenhagen'))
|
||||||
|
if (now - updated).total_seconds() > MissionService.get_ring_timeout_seconds():
|
||||||
|
continue
|
||||||
|
incoming.append({key: row.get(key) for key in ('call_id', 'caller_number', 'contact_name', 'company_name', 'ringing_phones')})
|
||||||
|
return {'allowed': True, 'incoming': incoming}
|
||||||
|
|
||||||
|
|
||||||
|
async def call(payload, request):
|
||||||
|
"""Persist a reservation before external I/O; never automatically redial on uncertainty."""
|
||||||
|
from app.modules.telefoni.backend.router import click_to_call
|
||||||
|
from app.modules.telefoni.backend.schemas import TelefoniClickToCallRequest
|
||||||
|
user = actor(remote_device(request))
|
||||||
|
require_access(user)
|
||||||
|
uid = user['id']
|
||||||
|
value = {'action': 'call', **payload.model_dump(mode='json')}
|
||||||
|
with atomic_transaction():
|
||||||
|
execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (uid,))
|
||||||
|
user = actor(remote_device(request)); require_access(user)
|
||||||
|
old = execute_query_single('SELECT payload,result FROM remote_commands WHERE user_id=%s AND request_id=%s', (uid, str(payload.request_id)))
|
||||||
|
if old:
|
||||||
|
if old['payload'] != value:
|
||||||
|
raise HTTPException(409, 'Handlingen har allerede andre oplysninger')
|
||||||
|
return old['result']
|
||||||
|
contact = execute_query_single('SELECT id,phone,mobile FROM contacts WHERE id=%s AND is_active=TRUE', (payload.contact_id,))
|
||||||
|
if not contact or not contact.get(payload.field):
|
||||||
|
raise HTTPException(404, 'Kontakten har ikke det valgte telefonnummer')
|
||||||
|
# Only stored phone numbers can reach the existing action URL template.
|
||||||
|
import re
|
||||||
|
number = str(contact[payload.field]).strip()
|
||||||
|
if not re.fullmatch(r'\+?[0-9][0-9 () .-]{1,39}', number):
|
||||||
|
raise HTTPException(400, 'Kontaktens telefonnummer skal rettes i Hub')
|
||||||
|
pending = {'status': 'uncertain', 'message': 'Opkald er afsendt eller under behandling. Kontrollér telefonen før et nyt opkald.'}
|
||||||
|
execute_query('INSERT INTO remote_commands(user_id,request_id,payload,result) VALUES(%s,%s,%s,%s)',
|
||||||
|
(uid, str(payload.request_id), Json(value), Json(pending)))
|
||||||
|
request.state.user_id = uid
|
||||||
|
try:
|
||||||
|
await click_to_call(TelefoniClickToCallRequest(number=number, user_id=uid), request)
|
||||||
|
result = {'status': 'ok', 'message': 'Opkald startet på din VoIP-telefon'}
|
||||||
|
except HTTPException as exc:
|
||||||
|
# A timeout can mean that the phone received the command. Never redial it.
|
||||||
|
result = {'status': 'failed' if exc.status_code < 500 else 'uncertain',
|
||||||
|
'message': 'Opkald kunne ikke startes. Kontrollér telefonopsætningen i Hub.' if exc.status_code < 500 else pending['message']}
|
||||||
|
except Exception:
|
||||||
|
result = pending
|
||||||
|
execute_query('UPDATE remote_commands SET result=%s WHERE user_id=%s AND request_id=%s',
|
||||||
|
(Json(result), uid, str(payload.request_id)))
|
||||||
|
return result
|
||||||
@ -125,7 +125,7 @@ templates.env.filters["safe_case_html"] = sanitize_safe_html
|
|||||||
def _fetch_assignment_users():
|
def _fetch_assignment_users():
|
||||||
return execute_query(
|
return execute_query(
|
||||||
"""
|
"""
|
||||||
SELECT user_id, COALESCE(full_name, username) AS display_name
|
SELECT user_id, username, full_name, COALESCE(full_name, username) AS display_name
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY display_name
|
ORDER BY display_name
|
||||||
""",
|
""",
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
{% block title %}Ny Sag - BMC Hub{% endblock %}
|
{% block title %}Ny Sag - BMC Hub{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="/static/css/case-command-composer.css?v=20261006-4">
|
||||||
|
<link rel="stylesheet" href="/static/css/case-create-v5.css?v=20261014-1">
|
||||||
<style>
|
<style>
|
||||||
.cc-panel { border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; margin: 0 0 12px; background: var(--bg-surface, #fff); }
|
.cc-panel { border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; margin: 0 0 12px; background: var(--bg-surface, #fff); }
|
||||||
.cc-panel > summary { padding: 14px 16px; cursor: pointer; font-weight: 600; }
|
.cc-panel > summary { padding: 14px 16px; cursor: pointer; font-weight: 600; }
|
||||||
@ -287,6 +289,7 @@
|
|||||||
<script src="/static/js/case-types.js?v=20261004-1"></script>
|
<script src="/static/js/case-types.js?v=20261004-1"></script>
|
||||||
<div class="container py-4 cc-page">
|
<div class="container py-4 cc-page">
|
||||||
<div class="cc-pagebar"><a href="/sag"><i class="bi bi-arrow-left me-2"></i>Alle sager</a></div>
|
<div class="cc-pagebar"><a href="/sag"><i class="bi bi-arrow-left me-2"></i>Alle sager</a></div>
|
||||||
|
<header class="cc-v5-hero"><span class="cc-v5-eyebrow">SAGSBORD</span><h1>Ny sag</h1><p>Beskriv sagen, vælg kontakt og tilføj handlinger med /.</p></header>
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<!-- Main Card -->
|
<!-- Main Card -->
|
||||||
@ -384,7 +387,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..." required></textarea>
|
<textarea class="form-control" id="beskrivelse" rows="6" placeholder="Beskriv sagen. Skriv / på en ny linje for handlinger…" 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>
|
||||||
@ -1556,8 +1559,35 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Form Submission ---
|
// --- Form Submission ---
|
||||||
|
let caseCreationSubmitting = false;
|
||||||
document.getElementById('createForm').addEventListener('submit', async (e) => {
|
document.getElementById('createForm').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (caseCreationSubmitting) return;
|
||||||
|
caseCreationSubmitting = true;
|
||||||
|
try {
|
||||||
|
const commands = window.caseCreateCommands;
|
||||||
|
if (commands) await commands.ready;
|
||||||
|
if (commands?.hasPending()) {
|
||||||
|
const btn = document.getElementById('submitBtn');
|
||||||
|
const caseId = commands.pendingCaseId();
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await commands.retry();
|
||||||
|
window.caseCreateUI?.created({id: caseId});
|
||||||
|
window.location.href = `/sag/${caseId}/v5`;
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('error').classList.remove('d-none');
|
||||||
|
document.getElementById('error-text').textContent = err.message;
|
||||||
|
} finally { btn.disabled = false; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let commandDraft = null;
|
||||||
|
try { commandDraft = commands?.prepare(); }
|
||||||
|
catch (err) {
|
||||||
|
document.getElementById('error').classList.remove('d-none');
|
||||||
|
document.getElementById('error-text').textContent = err.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (window.caseCreateUI && !window.caseCreateUI.validate()) return;
|
if (window.caseCreateUI && !window.caseCreateUI.validate()) return;
|
||||||
|
|
||||||
// UI Reset
|
// UI Reset
|
||||||
@ -1583,6 +1613,7 @@
|
|||||||
|
|
||||||
// Loading State
|
// Loading State
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
commands?.setCreating(true);
|
||||||
const originalBtnText = btn.innerHTML;
|
const originalBtnText = btn.innerHTML;
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
|
||||||
|
|
||||||
@ -1619,13 +1650,14 @@
|
|||||||
errorDiv.classList.remove('d-none');
|
errorDiv.classList.remove('d-none');
|
||||||
document.getElementById('error-text').textContent = err.message;
|
document.getElementById('error-text').textContent = err.message;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
commands?.setCreating(false);
|
||||||
btn.innerHTML = originalBtnText;
|
btn.innerHTML = originalBtnText;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
titel: titel,
|
titel: titel,
|
||||||
beskrivelse: document.getElementById('beskrivelse').value || '',
|
beskrivelse: commandDraft?.comment ?? (document.getElementById('beskrivelse').value || ''),
|
||||||
type: document.getElementById('type').value,
|
type: document.getElementById('type').value,
|
||||||
status: status,
|
status: status,
|
||||||
customer_id: selectedCustomer ? selectedCustomer.id : null,
|
customer_id: selectedCustomer ? selectedCustomer.id : null,
|
||||||
@ -1662,6 +1694,7 @@
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
await commands?.created(result);
|
||||||
window.caseCreateUI?.created(result);
|
window.caseCreateUI?.created(result);
|
||||||
|
|
||||||
successDiv.classList.remove('d-none');
|
successDiv.classList.remove('d-none');
|
||||||
@ -1682,9 +1715,15 @@
|
|||||||
errorDiv.classList.remove('d-none');
|
errorDiv.classList.remove('d-none');
|
||||||
document.getElementById('error-text').textContent = err.message;
|
document.getElementById('error-text').textContent = err.message;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = originalBtnText;
|
commands?.setCreating(false);
|
||||||
|
btn.innerHTML = commands?.hasPending() ? 'Prøv kommandoerne igen' : originalBtnText;
|
||||||
}
|
}
|
||||||
|
} finally { caseCreationSubmitting = false; }
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/case-create.js?v=14"></script>
|
<script src="/static/js/case-create.js?v=14"></script>
|
||||||
|
<script id="caseCreateCommandData" type="application/json">{{ {'users': assignment_users or [], 'groups': assignment_groups or []}|tojson }}</script>
|
||||||
|
<script src="/static/js/case-commands.js?v=20261006-1"></script>
|
||||||
|
<script src="/static/js/case-command-suggestions.js?v=20261014-initials"></script>
|
||||||
|
<script src="/static/js/case-create-commands.js?v=20261014-1"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
{% block title %}{{ case.titel }} · Sagsbord V5 - BMC Hub{% endblock %}
|
{% block title %}{{ case.titel }} · Sagsbord V5 - BMC Hub{% endblock %}
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<link rel="stylesheet" href="/static/css/case-v5.css?v=20261007-4">
|
<link rel="stylesheet" href="/static/css/case-v5.css?v=20261014-call-list">
|
||||||
<link rel="stylesheet" href="/static/css/case-command-composer.css?v=20261006-4">
|
<link rel="stylesheet" href="/static/css/case-command-composer.css?v=20261006-4">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -17,12 +17,15 @@
|
|||||||
<div id="v5ActionOptions" class="v5-action-options"></div>
|
<div id="v5ActionOptions" class="v5-action-options"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="v5AssignmentFields" class="v5-assignment-fields" hidden>
|
<div id="v5AssignmentFields" class="v5-assignment-fields" hidden>
|
||||||
<label for="v5GroupSearch">Søg gruppe</label>
|
<label for="v5UserSearch">Vælg medarbejder</label>
|
||||||
<input id="v5GroupSearch" class="form-control form-control-sm" type="search" placeholder="Find gruppe…" autocomplete="off">
|
<input id="v5UserSearch" class="form-control form-control-sm" type="search" placeholder="Søg navn eller initialer…" autocomplete="off" disabled>
|
||||||
<div id="v5GroupOptions" class="v5-picker" aria-label="Vælg gruppe"></div>
|
<div id="v5UserOptions" class="v5-picker v5-employee-buttons" aria-label="Vælg ansvarlig medarbejder"></div>
|
||||||
<label for="v5UserSearch">Søg ansvarlig medarbejder</label>
|
<details class="v5-assignment-groups">
|
||||||
<input id="v5UserSearch" class="form-control form-control-sm" type="search" placeholder="Vælg først en gruppe…" autocomplete="off" disabled>
|
<summary>Gruppe (valgfrit)</summary>
|
||||||
<div id="v5UserOptions" class="v5-picker" aria-label="Vælg ansvarlig medarbejder"></div>
|
<label for="v5GroupSearch" class="visually-hidden">Søg gruppe</label>
|
||||||
|
<input id="v5GroupSearch" class="form-control form-control-sm" type="search" placeholder="Find gruppe…" autocomplete="off">
|
||||||
|
<div id="v5GroupOptions" class="v5-picker" aria-label="Vælg gruppe"></div>
|
||||||
|
</details>
|
||||||
<label for="v5AssignmentReason">Begrundelse for tildeling <span class="text-danger">*</span></label>
|
<label for="v5AssignmentReason">Begrundelse for tildeling <span class="text-danger">*</span></label>
|
||||||
<textarea id="v5AssignmentReason" class="form-control" rows="2" maxlength="2000" placeholder="Hvorfor overdrages sagen?"></textarea>
|
<textarea id="v5AssignmentReason" class="form-control" rows="2" maxlength="2000" placeholder="Hvorfor overdrages sagen?"></textarea>
|
||||||
<p class="small text-muted mt-2 mb-0">Kommentar og begrundelse gemmes internt. Andre medarbejdere i den valgte gruppe bevares.</p>
|
<p class="small text-muted mt-2 mb-0">Kommentar og begrundelse gemmes internt. Andre medarbejdere i den valgte gruppe bevares.</p>
|
||||||
@ -35,8 +38,8 @@
|
|||||||
'groupId': case.assigned_group_id,
|
'groupId': case.assigned_group_id,
|
||||||
'userId': case.ansvarlig_bruger_id
|
'userId': case.ansvarlig_bruger_id
|
||||||
}|tojson }}</script>
|
}|tojson }}</script>
|
||||||
<script src="/static/js/case-v5.js?v=20261008-4"></script>
|
<script src="/static/js/case-v5.js?v=20261014-call-list"></script>
|
||||||
<script src="/static/js/case-commands.js?v=20261006-1"></script>
|
<script src="/static/js/case-commands.js?v=20261006-1"></script>
|
||||||
<script src="/static/js/case-command-suggestions.js?v=20261006-1"></script>
|
<script src="/static/js/case-command-suggestions.js?v=20261014-initials"></script>
|
||||||
<script src="/static/js/case-command-composer.js?v=20261008-3"></script>
|
<script src="/static/js/case-command-composer.js?v=20261014-first-suggestion-enter"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -2026,7 +2026,7 @@ if (bmcOriginalFetch) {
|
|||||||
<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" %}
|
||||||
<script src="/static/js/message-ui.js?v=1"></script>
|
<script src="/static/js/message-ui.js?v=1"></script>
|
||||||
<script src="/static/js/bottom-bar.js?v=2.72"></script>
|
<script src="/static/js/bottom-bar.js?v=2.73"></script>
|
||||||
<script>
|
<script>
|
||||||
// Dark Mode Toggle Logic
|
// Dark Mode Toggle Logic
|
||||||
window.BMC_CAN_CLICK_TO_CALL = true;
|
window.BMC_CAN_CLICK_TO_CALL = true;
|
||||||
|
|||||||
@ -2,36 +2,65 @@
|
|||||||
{% block title %}Tagklassifikation – BMC Hub{% endblock %}
|
{% block title %}Tagklassifikation – BMC Hub{% endblock %}
|
||||||
{% block extra_css %}{% include 'workflows/frontend/_style.html' %}
|
{% block extra_css %}{% include 'workflows/frontend/_style.html' %}
|
||||||
<style>
|
<style>
|
||||||
.workflow-page .classification-tree{max-height:60vh;overflow:auto}.workflow-page .rule{display:grid!important;grid-template-columns:150px minmax(130px,1fr) minmax(130px,1fr) 85px 32px;align-items:center}.workflow-page .rule .btn{padding:.3rem}.workflow-page #history{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.78rem}@media(max-width:767px){.workflow-page .rule{grid-template-columns:1fr 1fr}.workflow-page .rule .words,.workflow-page .rule .negatives{grid-column:1 / -1}.workflow-page .classification-tree{max-height:280px}}
|
.workflow-page .classification-tree{max-height:65vh}.classification-tree .list-group-item{padding:.55rem .75rem;border:0;border-bottom:1px solid var(--bs-border-color);background:var(--bg-card);color:var(--text-primary)}
|
||||||
|
.classification-tree .list-group-item.active{background:var(--accent-light);color:var(--accent);border-left:3px solid var(--accent);font-weight:600}
|
||||||
|
.tag-parent{display:block;font-size:.75rem;color:var(--text-secondary);font-weight:400}
|
||||||
|
.tag-settings summary,.rule-advanced summary{cursor:pointer;color:var(--text-secondary);font-size:.82rem}
|
||||||
|
.workflow-page .rule{display:block!important;padding:.75rem;border:1px solid var(--bs-border-color);border-radius:.6rem;background:var(--bg-card)}
|
||||||
|
.rule-main{display:flex;align-items:center;gap:.5rem}.rule-main .words{flex:1}.rule-advanced{margin-top:.4rem}.rule-options{display:grid;grid-template-columns:1fr 2fr 90px;gap:.5rem;margin-top:.5rem}.rule-options label{font-size:.75rem;color:var(--text-secondary)}
|
||||||
|
#saveRules{min-width:150px}.tag-settings{margin-bottom:.75rem}
|
||||||
|
@media(max-width:767px){.rule-options{grid-template-columns:1fr}.workflow-page .classification-tree{max-height:250px}}
|
||||||
|
|
||||||
|
.workflow-page #history{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.78rem}
|
||||||
</style>{% endblock %}
|
</style>{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="workflow-page">
|
<div class="workflow-page">
|
||||||
<header class="workflow-heading"><div><div class="workflow-eyebrow">Automatisering</div><h1><i class="bi bi-tags me-2"></i>Tagklassifikation</h1><p>Organisér tags, vedligehold nøgleord og gennemgå automatiske forslag.</p></div><a class="btn btn-sm btn-outline-secondary" href="/tags"><i class="bi bi-arrow-left me-1"></i>Tagadministration</a></header>
|
<header class="workflow-heading"><div><div class="workflow-eyebrow">Automatisering</div><h1><i class="bi bi-tags me-2"></i>Tagklassifikation</h1><p>Vælg et tag, tilføj de ord der skal finde det, og gem.</p></div><a class="btn btn-sm btn-outline-secondary" href="/tags"><i class="bi bi-arrow-left me-1"></i>Tagadministration</a></header>
|
||||||
{% include 'workflows/frontend/_nav.html' %}
|
{% include 'workflows/frontend/_nav.html' %}
|
||||||
|
<section class="card p-3 mb-3 classification-guide" aria-label="Sådan bruges tagklassifikation">
|
||||||
|
<h2 class="h5">Hjælp systemet med at foreslå de rigtige tags</h2>
|
||||||
|
<p class="small mb-2">Her bestemmer du, hvilke ord systemet skal genkende i tekst på sager, tidsregistreringer og mails. Tags gør det lettere at finde og sortere arbejdet og kan indgå i workflows.</p>
|
||||||
|
<div class="row g-3 small">
|
||||||
|
<div class="col-md-4"><strong>1. Find et tag</strong><div class="text-muted">Søg til venstre, og vælg fx “Printer”. Tagget skal være oprettet i <a href="/tags">Tagadministration</a>.</div></div>
|
||||||
|
<div class="col-md-4"><strong>2. Tilføj nøgleord og gem</strong><div class="text-muted">Skriv fx “printer, udskrivning, papir”. Ét af ordene er nok til at matche. Afprøv en tekst, og vælg “Gem ændringer”.</div></div>
|
||||||
|
<div class="col-md-4"><strong>3. Gennemgå forslag</strong><div class="text-muted">Under “Forslag” vælger du sag, tid eller mail og indtaster dens ID. Her kan du godkende eller afvise eksisterende forslag.</div></div>
|
||||||
|
</div>
|
||||||
|
<details class="mt-2 small"><summary>Eksempel og forskellen på tags og afregning</summary>
|
||||||
|
<p class="mt-2 mb-2">Teksten “Printeren vil ikke udskrive” matcher nøgleordet “printer” og kan give tagget “Printer”. “Afprøv” tester kun teksten i feltet; den ændrer ikke en sag og opretter ikke et forslag.</p>
|
||||||
|
<p class="mb-2">Nøgleord bruges, når systemets klassifikation kører. At gemme her starter ikke en gennemgang af alle eksisterende sager, timer eller mails. Om et match tilføjes automatisk eller kræver gennemgang, afhænger af klassifikationsforløbet.</p>
|
||||||
|
<p class="mb-0">Denne side vælger ikke fastpris, klippekort eller faktura. Det styres af aftalernes dækning og <a href="/workflows">workflowregler</a>. “Kan påvirke afregning” markerer et tag som relevant for afregning; det opretter ikke en afregningsregel.</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<div class="btn-group mb-3" aria-label="Visning"><button class="btn btn-primary classification-tab" data-panel="tagWorkspace">Tags og nøgleord</button><button class="btn btn-outline-primary classification-tab" data-panel="proposalWorkspace">Forslag</button></div>
|
||||||
<div id="notice" role="status" aria-live="polite" class="my-2"></div>
|
<div id="notice" role="status" aria-live="polite" class="my-2"></div>
|
||||||
<div class="row g-3">
|
<div class="row g-3" id="tagWorkspace">
|
||||||
<aside class="col-lg-4"><section class="card p-3"><label for="search" class="mb-1">Søg tag eller overtag</label><input id="search" class="form-control mb-3" placeholder="Fx Excel eller Microsoft"><div id="tree" class="list-group classification-tree" aria-label="Taghierarki"><div class="workflow-empty">Henter tags…</div></div></section></aside>
|
<aside class="col-lg-4"><section class="card p-3"><label for="search" class="mb-1">Find tag</label><input id="search" class="form-control mb-3" placeholder="Skriv et tagnavn…"><div id="tagCount" class="small text-muted mb-2"></div><div id="tree" class="list-group classification-tree" aria-label="Taghierarki"><div class="workflow-empty">Henter tags…</div></div></section></aside>
|
||||||
<section class="col-lg-8"><div class="card p-3"><h5 id="selected">Vælg et tag i oversigten</h5><p class="small text-muted">Overtagets regler kan omfatte alle undertags. Kun det konkrete tag behøver at være tildelt.</p>
|
<section class="col-lg-8"><div class="card p-3"><h5 id="selected">Vælg et tag i oversigten</h5><p id="tagHint" class="small text-muted">Find et tag til venstre for at redigere dets nøgleord.</p>
|
||||||
<form id="edit" hidden><div class="row g-2"><div class="col-md-7"><label for="parent">Overtag</label><select id="parent" class="form-select"></select></div><div class="col-md-5"><label for="visibility">Synlighed</label><select id="visibility" class="form-select"><option value="public">Offentlig</option><option value="internal">Intern</option></select></div></div><label class="my-3"><input id="billing" type="checkbox" class="form-check-input me-1"> Kan påvirke afregning</label><div><button type="submit" class="btn btn-primary">Gem hierarki</button></div></form>
|
<form id="edit" hidden><details class="tag-settings"><summary>Tagindstillinger: overtag, synlighed og afregning</summary><div class="row g-2 mt-2"><div class="col-md-7"><label for="parent">Overtag (valgfrit)</label><select id="parent" class="form-select"></select></div><div class="col-md-5"><label for="visibility">Synlighed</label><select id="visibility" class="form-select"><option value="public">Offentlig</option><option value="internal">Intern</option></select></div></div><label class="my-3"><input id="billing" type="checkbox" class="form-check-input me-1"> Kan påvirke afregning</label><p class="small text-muted">Et overtag grupperer beslægtede tags. Fx Microsoft → Excel. Afregningsregler kan også omfatte undertags.</p></details></form>
|
||||||
<div id="keywordsPanel" hidden class="mt-3 border-top pt-3"><h5>Nøgleordsregler</h5><p class="small text-muted">En regel pr. række. Adskil alternative og negative ord med komma. Laveste prioritet behandles først.</p><div id="keywords"></div><div class="workflow-actions mt-2"><button type="button" id="addRule" class="btn btn-outline-secondary"><i class="bi bi-plus me-1"></i>Tilføj regel</button><button type="button" id="saveRules" class="btn btn-primary">Gem regler</button></div></div>
|
<div id="keywordsPanel" hidden class="mt-3 border-top pt-3"><h5>Hvornår skal tagget foreslås?</h5><p class="small text-muted">Skriv ord eller udtryk, adskilt med komma. Fx: printer, udskrivning, papir.</p><div id="keywords"></div><div class="workflow-actions mt-2"><button type="button" id="addRule" class="btn btn-outline-secondary"><i class="bi bi-plus me-1"></i>Tilføj flere nøgleord</button><button type="button" id="saveRules" class="btn btn-primary">Gem ændringer</button></div><div class="mt-3"><label for="testText" class="form-label small">Prøv med en tekst</label><div class="d-flex gap-2"><input id="testText" class="form-control" placeholder="Fx printeren vil ikke udskrive"><button id="testRules" class="btn btn-outline-primary" type="button">Afprøv</button></div><div id="testResult" class="small mt-2" role="status"></div></div></div>
|
||||||
</div></section>
|
</div></section>
|
||||||
</div>
|
</div>
|
||||||
<section class="card p-3 mt-3"><h5>Forslag og historik</h5><p class="small text-muted">Vælg sag, tid eller mail og indtast dens ID. Godkend eller afvis forslag med en begrundelse.</p><div class="row g-2 align-items-end"><div class="col-sm-4 col-lg-3"><label for="entityType">Område</label><select id="entityType" class="form-select"><option value="time_entry">Tidsregistrering</option><option value="case">Sag</option><option value="email">Mail</option></select></div><div class="col-sm-4 col-lg-3"><label for="entityId">ID</label><input id="entityId" type="number" min="1" class="form-control" placeholder="Fx 4521"></div><div class="col-sm-4"><button type="button" id="loadProposals" class="btn btn-outline-primary">Vis forslag</button></div></div><div id="proposals" class="mt-3"><div class="workflow-empty">Vælg et område og et ID for at se forslag.</div></div><details class="mt-3"><summary>Taghistorik</summary><div id="history" class="mt-2">Vælg data ovenfor.</div></details></section>
|
<section id="proposalWorkspace" hidden class="card p-3"><h5>Forslag og historik</h5><p class="small text-muted">Gennemgå forslag, som systemet allerede har oprettet. Vælg område og indtast ID’et fra sagen, tidsregistreringen eller mailen. Skriv en begrundelse, før du godkender eller afviser.</p><div class="row g-2 align-items-end"><div class="col-sm-4 col-lg-3"><label for="entityType">Område</label><select id="entityType" class="form-select"><option value="time_entry">Tidsregistrering</option><option value="case">Sag</option><option value="email">Mail</option></select></div><div class="col-sm-4 col-lg-3"><label for="entityId">ID</label><input id="entityId" type="number" min="1" class="form-control" placeholder="Fx 4521"></div><div class="col-sm-4"><button type="button" id="loadProposals" class="btn btn-outline-primary">Vis forslag</button></div></div><div id="proposals" class="mt-3"><div class="workflow-empty">Vælg et område og et ID for at se forslag.</div></div><details class="mt-3"><summary>Taghistorik</summary><div id="history" class="mt-2">Vælg data ovenfor.</div></details></section>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
const root='/api/v1/tags/classification';let tags=[],selected=null;
|
const root='/api/v1/tags/classification';let tags=[],selected=null,dirty=false,selectionGeneration=0,saving=false;
|
||||||
|
function markDirty(){dirty=true;el('saveRules').textContent='Gem ændringer';}
|
||||||
|
window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});
|
||||||
const el=id=>document.getElementById(id);const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const el=id=>document.getElementById(id);const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
async function api(path,method='GET',body){const r=await fetch(root+path,{method,headers:{'Content-Type':'application/json'},...(body!==undefined?{body:JSON.stringify(body)}:{})});const data=await r.json();if(!r.ok)throw Error(data.detail||'Kunne ikke gemme');return data}
|
async function api(path,method='GET',body){const r=await fetch(root+path,{method,headers:{'Content-Type':'application/json'},...(body!==undefined?{body:JSON.stringify(body)}:{})});const data=await r.json();if(!r.ok)throw Error(data.detail||'Kunne ikke gemme');return data}
|
||||||
function report(error){el('notice').textContent=error.message||error;el('notice').className='alert alert-danger';}function saved(text){el('notice').textContent=text;el('notice').className='alert alert-success';}
|
function report(error){el('notice').textContent=error.message||error;el('notice').className='alert alert-danger';}function saved(text){el('notice').textContent=text;el('notice').className='alert alert-success';}
|
||||||
function label(t){let path=[t.name],seen=new Set([t.id]),p=t.parent_id;while(p){let parent=tags.find(x=>x.id===p);if(!parent||seen.has(p))break;seen.add(p);path.unshift(parent.name);p=parent.parent_id}return path.join(' / ')}
|
function label(t){let path=[t.name],seen=new Set([t.id]),p=t.parent_id;while(p){let parent=tags.find(x=>x.id===p);if(!parent||seen.has(p))break;seen.add(p);path.unshift(parent.name);p=parent.parent_id}return path.join(' / ')}
|
||||||
function renderTree(){const q=el('search').value.toLowerCase();el('tree').innerHTML=tags.filter(t=>label(t).toLowerCase().includes(q)).map(t=>`<button class="list-group-item list-group-item-action" data-id="${t.id}">${esc(label(t))}</button>`).join('')||'<div class="workflow-empty">Ingen tags matcher søgningen.</div>';el('tree').querySelectorAll('button').forEach(b=>b.onclick=()=>selectTag(Number(b.dataset.id)).catch(report))}
|
function renderTree(){const q=el('search').value.toLocaleLowerCase('da-DK');const rows=tags.filter(t=>label(t).toLocaleLowerCase('da-DK').includes(q));el('tagCount').textContent=rows.length+' tags';el('tree').innerHTML=rows.map(t=>`<button type="button" class="list-group-item list-group-item-action ${selected?.id===t.id?'active':''}" data-id="${t.id}" aria-pressed="${selected?.id===t.id}">${esc(t.name)}${t.parent_id?`<span class="tag-parent">${esc(label(t).split(' / ').slice(0,-1).join(' / '))}</span>`:''}</button>`).join('')||'<div class="workflow-empty">Ingen tags matcher søgningen.</div>';el('tree').querySelectorAll('button').forEach(b=>b.onclick=()=>selectTag(Number(b.dataset.id)).catch(report))}
|
||||||
async function selectTag(id){selected=tags.find(t=>t.id===id);el('selected').textContent=label(selected);el('edit').hidden=false;el('keywordsPanel').hidden=false;el('parent').innerHTML='<option value="">Intet overtag</option>'+tags.filter(t=>t.id!==id).map(t=>`<option value="${t.id}">${esc(label(t))}</option>`).join('');el('parent').value=selected.parent_id||'';el('visibility').value=selected.visibility;el('billing').checked=selected.billing_relevant;el('keywords').innerHTML='';(await api('/keywords/'+id)).forEach(addRule)}
|
async function selectTag(id){if(saving)return;if(dirty&&!confirm('Du har ændringer, som ikke er gemt. Vil du skifte tag og kassere dem?'))return;const generation=++selectionGeneration;selected=tags.find(t=>t.id===id);dirty=false;renderTree();el('selected').textContent=selected.name;el('tagHint').textContent='Nøgleord foreslår dette tag, når de findes i teksten.';el('edit').hidden=false;el('keywordsPanel').hidden=false;el('parent').innerHTML='<option value="">Intet overtag</option>'+tags.filter(t=>t.id!==id).map(t=>`<option value="${t.id}">${esc(label(t))}</option>`).join('');el('parent').value=selected.parent_id||'';el('visibility').value=selected.visibility;el('billing').checked=selected.billing_relevant;el('keywords').innerHTML='Henter nøgleord…';el('saveRules').disabled=true;el('addRule').disabled=true;el('testResult').textContent='';try{const rules=await api('/keywords/'+id);if(generation!==selectionGeneration)return;el('keywords').innerHTML='';rules.forEach(addRule);if(!rules.length)addRule();dirty=false;el('saveRules').textContent='Gem ændringer';el('saveRules').disabled=false;el('addRule').disabled=false;}catch(error){if(generation===selectionGeneration)el('keywords').textContent='Nøgleord kunne ikke hentes. Vælg tagget igen for at prøve på ny.';throw error;}}
|
||||||
function addRule(rule={}){const row=document.createElement('div');row.className='rule d-flex gap-1 mb-2';row.innerHTML=`<select class="form-select mode"><option value="contains">Indeholder</option><option value="exact">Eksakt tekst</option><option value="whole_word">Hele ord</option></select><input class="form-control words" aria-label="Alternative ord" placeholder="Alternative ord" value="${esc((rule.alternatives||[]).join(', '))}"><input class="form-control negatives" aria-label="Negative ord" placeholder="Negative ord" value="${esc((rule.negative_words||[]).join(', '))}"><input class="form-control priority" aria-label="Prioritet" type="number" value="${rule.priority??100}" style="width:85px"><button class="btn btn-outline-danger" aria-label="Fjern regel">×</button>`;row.querySelector('.mode').value=rule.mode||'contains';row.querySelector('button').onclick=()=>row.remove();el('keywords').append(row)}
|
function addRule(rule={}){const row=document.createElement('div');row.className='rule mb-2';row.innerHTML=`<div class="rule-main"><input class="form-control words" aria-label="Nøgleord" placeholder="Fx printer, udskrivning, papir" value="${esc((rule.alternatives||[]).join(', '))}"><button type="button" class="btn btn-outline-secondary" aria-label="Fjern nøgleordsregel">×</button></div><details class="rule-advanced"><summary>Avanceret: undtagelser og match</summary><div class="rule-options"><label>Match<select class="form-select mode"><option value="contains">Indeholder teksten</option><option value="exact">Præcis samme tekst</option><option value="whole_word">Kun hele ord</option></select></label><label>Foreslå ikke ved disse ord<input class="form-control negatives" aria-label="Negative ord" placeholder="Fx toner, bestilling" value="${esc((rule.negative_words||[]).join(', '))}"></label><label>Prioritet<input class="form-control priority" aria-label="Prioritet" type="number" value="${Number(rule.priority??100)}"></label></div></details>`;row.querySelector('.mode').value=rule.mode||'contains';row.querySelector('button').onclick=()=>{row.remove();markDirty();};row.oninput=markDirty;row.onchange=markDirty;el('keywords').append(row)}
|
||||||
el('search').oninput=renderTree;el('addRule').onclick=()=>addRule();
|
function rulePayload(){const split=v=>v.split(',').map(x=>x.trim()).filter(Boolean);return [...el('keywords').children].map(r=>({mode:r.querySelector('.mode').value,alternatives:split(r.querySelector('.words').value),negative_words:split(r.querySelector('.negatives').value),priority:Number(r.querySelector('.priority').value)})).filter(r=>r.alternatives.length)}
|
||||||
el('edit').onsubmit=async e=>{e.preventDefault();try{await api('/hierarchy/'+selected.id,'PUT',{parent_id:el('parent').value?Number(el('parent').value):null,visibility:el('visibility').value,billing_relevant:el('billing').checked});tags=await api('/hierarchy');renderTree();await selectTag(selected.id);saved('Hierarki gemt.')}catch(err){report(err)}};
|
el('search').oninput=renderTree;el('addRule').onclick=()=>{addRule();markDirty();el('keywords').lastElementChild.querySelector('.words').focus();};el('edit').onchange=markDirty;
|
||||||
el('saveRules').onclick=async()=>{try{const split=v=>v.split(',').map(x=>x.trim()).filter(Boolean);await api('/keywords/'+selected.id,'PUT',[...el('keywords').children].map(r=>({mode:r.querySelector('.mode').value,alternatives:split(r.querySelector('.words').value),negative_words:split(r.querySelector('.negatives').value),priority:Number(r.querySelector('.priority').value)})));saved('Nøgleordsregler gemt.')}catch(err){report(err)}};
|
el('edit').onsubmit=e=>{e.preventDefault();el('saveRules').click();};
|
||||||
|
el('saveRules').onclick=async()=>{const button=el('saveRules'),id=selected?.id;if(!id||saving)return;saving=true;el('edit').inert=true;el('keywordsPanel').inert=true;button.disabled=true;el('addRule').disabled=true;const rules=rulePayload(),hierarchy={parent_id:el('parent').value?Number(el('parent').value):null,visibility:el('visibility').value,billing_relevant:el('billing').checked};let hierarchySaved=false;try{await api('/hierarchy/'+id,'PUT',hierarchy);hierarchySaved=true;await api('/keywords/'+id,'PUT',rules);Object.assign(tags.find(t=>t.id===id),hierarchy);dirty=false;renderTree();saved('Ændringer gemt for '+selected.name+'.');button.textContent='Gemt';}catch(err){report(hierarchySaved?'Tagindstillingerne blev gemt, men nøgleordene blev ikke gemt. '+err.message:err);}finally{saving=false;el('edit').inert=false;el('keywordsPanel').inert=false;button.disabled=false;el('addRule').disabled=false;}};
|
||||||
|
el('testRules').onclick=()=>{const text=el('testText').value.toLocaleLowerCase('da-DK');if(!text.trim()){el('testResult').textContent='Skriv en tekst først.';return;}const matches=rulePayload().some(r=>{const match=word=>{word=word.toLocaleLowerCase('da-DK');if(r.mode==='exact')return text.trim()===word;if(r.mode==='whole_word'){const escape=v=>v.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');return new RegExp('(^|[^\\p{L}\\p{N}_])'+escape(word)+'($|[^\\p{L}\\p{N}_])','u').test(text);}return text.includes(word);};return r.alternatives.some(match)&&!r.negative_words.some(match);});el('testResult').textContent=(matches?'Matcher: '+selected.name:'Ingen match for '+selected.name)+'. Test af felterne ovenfor – intet gemmes.';};
|
||||||
|
document.querySelectorAll('.classification-tab').forEach(button=>button.onclick=()=>{for(const id of ['tagWorkspace','proposalWorkspace'])el(id).hidden=id!==button.dataset.panel;document.querySelectorAll('.classification-tab').forEach(b=>{b.classList.toggle('btn-primary',b===button);b.classList.toggle('btn-outline-primary',b!==button);});});
|
||||||
async function loadProposals(){if(!Number.isInteger(Number(el('entityId').value))||Number(el('entityId').value)<=0)throw Error('Angiv et gyldigt ID');const suffix='/'+el('entityType').value+'/'+Number(el('entityId').value);const rows=await api('/proposals'+suffix);el('proposals').innerHTML=rows.map(p=>`<div class="card p-2 mb-2"><strong>${esc(tags.find(t=>t.id===p.tag_id)?.name||p.tag_id)} · ${esc(p.status)} · ${esc(p.confidence)}%</strong><span>${esc(p.explanation)}</span>${['pending','manual_review'].includes(p.status)?`<input class="form-control reason" placeholder="Begrundelse" aria-label="Begrundelse"><div><button class="btn btn-success" data-id="${p.id}" data-approve="true">Godkend</button> <button class="btn btn-outline-danger" data-id="${p.id}" data-approve="false">Afvis</button></div>`:''}</div>`).join('')||'<div class="workflow-empty">Ingen klassifikationsforslag for de valgte data.</div>';el('proposals').querySelectorAll('button').forEach(b=>b.onclick=async()=>{try{const reason=b.closest('.card').querySelector('.reason').value.trim();if(!reason)throw Error('Angiv en begrundelse');await api('/proposals/'+b.dataset.id+'/review','POST',{approve:b.dataset.approve==='true',reason});await loadProposals()}catch(err){report(err)}});el('history').textContent=JSON.stringify(await api('/history'+suffix),null,2)}
|
async function loadProposals(){if(!Number.isInteger(Number(el('entityId').value))||Number(el('entityId').value)<=0)throw Error('Angiv et gyldigt ID');const suffix='/'+el('entityType').value+'/'+Number(el('entityId').value);const rows=await api('/proposals'+suffix);el('proposals').innerHTML=rows.map(p=>`<div class="card p-2 mb-2"><strong>${esc(tags.find(t=>t.id===p.tag_id)?.name||p.tag_id)} · ${esc(p.status)} · ${esc(p.confidence)}%</strong><span>${esc(p.explanation)}</span>${['pending','manual_review'].includes(p.status)?`<input class="form-control reason" placeholder="Begrundelse" aria-label="Begrundelse"><div><button class="btn btn-success" data-id="${p.id}" data-approve="true">Godkend</button> <button class="btn btn-outline-danger" data-id="${p.id}" data-approve="false">Afvis</button></div>`:''}</div>`).join('')||'<div class="workflow-empty">Ingen klassifikationsforslag for de valgte data.</div>';el('proposals').querySelectorAll('button').forEach(b=>b.onclick=async()=>{try{const reason=b.closest('.card').querySelector('.reason').value.trim();if(!reason)throw Error('Angiv en begrundelse');await api('/proposals/'+b.dataset.id+'/review','POST',{approve:b.dataset.approve==='true',reason});await loadProposals()}catch(err){report(err)}});el('history').textContent=JSON.stringify(await api('/history'+suffix),null,2)}
|
||||||
el('loadProposals').onclick=async()=>{const button=el('loadProposals');button.disabled=true;try{await loadProposals()}catch(e){report(e)}finally{button.disabled=false}};el('entityId').onkeydown=e=>{if(e.key==='Enter')el('loadProposals').click()};api('/hierarchy').then(data=>{tags=data;renderTree()}).catch(report);
|
el('loadProposals').onclick=async()=>{const button=el('loadProposals');button.disabled=true;try{await loadProposals()}catch(e){report(e)}finally{button.disabled=false}};el('entityId').onkeydown=e=>{if(e.key==='Enter')el('loadProposals').click()};api('/hierarchy').then(data=>{tags=data;renderTree()}).catch(report);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Query
|
|||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_update, execute_query_single
|
from app.core.database import execute_query, execute_update, execute_query_single
|
||||||
|
from app.timetracking.backend.timer_transaction import serialized_timer
|
||||||
from app.timetracking.backend.models import (
|
from app.timetracking.backend.models import (
|
||||||
TModuleSyncStats,
|
TModuleSyncStats,
|
||||||
TModuleApprovalStats,
|
TModuleApprovalStats,
|
||||||
@ -2498,6 +2499,7 @@ async def list_time_entries_v1(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/time/start", tags=["Internal"])
|
@router.post("/time/start", tags=["Internal"])
|
||||||
|
@serialized_timer
|
||||||
async def start_live_timer_v1(
|
async def start_live_timer_v1(
|
||||||
payload: Dict[str, Any] = Body(...),
|
payload: Dict[str, Any] = Body(...),
|
||||||
current_user: Optional[dict] = Depends(get_optional_user)
|
current_user: Optional[dict] = Depends(get_optional_user)
|
||||||
@ -2606,6 +2608,7 @@ async def start_live_timer_v1(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/time/stop", tags=["Internal"])
|
@router.post("/time/stop", tags=["Internal"])
|
||||||
|
@serialized_timer
|
||||||
async def stop_live_timer_v1(
|
async def stop_live_timer_v1(
|
||||||
payload: Dict[str, Any] = Body(...),
|
payload: Dict[str, Any] = Body(...),
|
||||||
current_user: Optional[dict] = Depends(get_optional_user)
|
current_user: Optional[dict] = Depends(get_optional_user)
|
||||||
@ -2694,6 +2697,7 @@ async def stop_live_timer_v1(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/time/pause", tags=["Internal"])
|
@router.post("/time/pause", tags=["Internal"])
|
||||||
|
@serialized_timer
|
||||||
async def pause_live_timer_v1(
|
async def pause_live_timer_v1(
|
||||||
current_user: Optional[dict] = Depends(get_optional_user)
|
current_user: Optional[dict] = Depends(get_optional_user)
|
||||||
):
|
):
|
||||||
@ -2743,6 +2747,7 @@ async def pause_live_timer_v1(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/time/resume", tags=["Internal"])
|
@router.post("/time/resume", tags=["Internal"])
|
||||||
|
@serialized_timer
|
||||||
async def resume_live_timer_v1(
|
async def resume_live_timer_v1(
|
||||||
payload: Dict[str, Any] = Body(default={}),
|
payload: Dict[str, Any] = Body(default={}),
|
||||||
current_user: Optional[dict] = Depends(get_optional_user)
|
current_user: Optional[dict] = Depends(get_optional_user)
|
||||||
@ -2905,6 +2910,7 @@ async def list_my_switchable_timers_v1(
|
|||||||
LEFT JOIN users u ON u.user_id = t.medarbejder_id
|
LEFT JOIN users u ON u.user_id = t.medarbejder_id
|
||||||
WHERE t.medarbejder_id = %s
|
WHERE t.medarbejder_id = %s
|
||||||
AND t.slut_tid IS NOT NULL
|
AND t.slut_tid IS NOT NULL
|
||||||
|
AND COALESCE(t.kilde, '') <> 'workflow_registered'
|
||||||
AND t.aktiv_timer = FALSE
|
AND t.aktiv_timer = FALSE
|
||||||
AND COALESCE(t.entry_status, 'afventer') <> 'godkendt'
|
AND COALESCE(t.entry_status, 'afventer') <> 'godkendt'
|
||||||
AND COALESCE(t.status, 'pending') = 'pending'
|
AND COALESCE(t.status, 'pending') = 'pending'
|
||||||
@ -2957,7 +2963,7 @@ async def list_team_timer_status_v1(
|
|||||||
LEFT JOIN users u ON u.user_id = t.medarbejder_id
|
LEFT JOIN users u ON u.user_id = t.medarbejder_id
|
||||||
WHERE (
|
WHERE (
|
||||||
(t.slut_tid IS NULL AND (t.aktiv_timer = TRUE OR t.paused_at IS NOT NULL))
|
(t.slut_tid IS NULL AND (t.aktiv_timer = TRUE OR t.paused_at IS NOT NULL))
|
||||||
OR (t.slut_tid IS NOT NULL AND COALESCE(t.entry_status, 'afventer') <> 'godkendt' AND COALESCE(t.status, 'pending') = 'pending')
|
OR (t.slut_tid IS NOT NULL AND COALESCE(t.kilde, '') <> 'workflow_registered' AND COALESCE(t.entry_status, 'afventer') <> 'godkendt' AND COALESCE(t.status, 'pending') = 'pending')
|
||||||
)
|
)
|
||||||
{employee_filter}
|
{employee_filter}
|
||||||
ORDER BY CASE WHEN t.aktiv_timer THEN 0 WHEN t.slut_tid IS NULL THEN 1 ELSE 2 END,
|
ORDER BY CASE WHEN t.aktiv_timer THEN 0 WHEN t.slut_tid IS NULL THEN 1 ELSE 2 END,
|
||||||
@ -3062,6 +3068,7 @@ async def get_case_billing_options(sag_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/time/manual", tags=["Internal"])
|
@router.post("/time/manual", tags=["Internal"])
|
||||||
|
@serialized_timer
|
||||||
async def create_manual_time_v1(
|
async def create_manual_time_v1(
|
||||||
payload: Dict[str, Any] = Body(...),
|
payload: Dict[str, Any] = Body(...),
|
||||||
current_user: Optional[dict] = Depends(get_optional_user)
|
current_user: Optional[dict] = Depends(get_optional_user)
|
||||||
@ -3332,6 +3339,49 @@ async def get_time_settlement_options_v1(time_id: int):
|
|||||||
return {**_settlement_options_for_case_entry(entry), 'workflow_active': workflow_active}
|
return {**_settlement_options_for_case_entry(entry), 'workflow_active': workflow_active}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/time/{time_id}/register", tags=["Internal"])
|
||||||
|
async def register_workflow_timer_v1(
|
||||||
|
time_id: int,
|
||||||
|
payload: Dict[str, Any] = Body(default={}),
|
||||||
|
current_user: Optional[dict] = Depends(get_optional_user),
|
||||||
|
):
|
||||||
|
"""Record a stopped workflow timer for review, without approval or settlement."""
|
||||||
|
user_id = _resolve_current_user_id(current_user)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(401, "Authentication required")
|
||||||
|
entry = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(404, "Time entry not found")
|
||||||
|
if int(entry.get('medarbejder_id') or 0) != int(user_id) and not _can_view_all_team_timers(current_user):
|
||||||
|
raise HTTPException(403, "Du kan kun registrere dine egne timere")
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.workflows.integration import active_for_customer
|
||||||
|
from app.workflows.settlement import workflow_customer
|
||||||
|
if not (settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE == 'active' and active_for_customer(workflow_customer(time_id))):
|
||||||
|
raise HTTPException(409, "Brug den normale registrering for denne timer")
|
||||||
|
if entry.get('aktiv_timer') or not entry.get('slut_tid') or entry.get('status') != 'pending' or entry.get('economy_settled_at') or entry.get('economy_order_draft_id') or entry.get('billed_via_thehub_id'):
|
||||||
|
raise HTTPException(409, "Kun afsluttet, ikke godkendt tid kan registreres")
|
||||||
|
work_type = str(payload.get('work_type') or entry.get('work_type') or 'support')
|
||||||
|
if work_type not in {'support', 'troubleshooting', 'development', 'maintenance', 'on_site', 'meeting', 'other'}:
|
||||||
|
raise HTTPException(400, "Invalid work_type")
|
||||||
|
try:
|
||||||
|
minutes = int(payload.get('fakturerbar_tid_min', entry.get('fakturerbar_tid_min') or 0))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(400, "Minutter skal være et helt tal")
|
||||||
|
if minutes <= 0:
|
||||||
|
raise HTTPException(400, "Angiv mindst ét minut")
|
||||||
|
updated = execute_query("""
|
||||||
|
UPDATE tmodule_times SET work_type=%s, fakturerbar_tid_min=%s,
|
||||||
|
entry_type='manuel', kilde='workflow_registered', updated_at=NOW()
|
||||||
|
WHERE id=%s AND status='pending' AND aktiv_timer=FALSE AND slut_tid IS NOT NULL
|
||||||
|
AND economy_settled_at IS NULL AND economy_order_draft_id IS NULL
|
||||||
|
AND billed_via_thehub_id IS NULL RETURNING *
|
||||||
|
""", (work_type, minutes, time_id))
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(409, "Timeren er ændret. Opdatér og prøv igen")
|
||||||
|
return updated[0]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/time/{time_id}/approve", tags=["Internal"])
|
@router.post("/time/{time_id}/approve", tags=["Internal"])
|
||||||
async def approve_time_entry_v1(
|
async def approve_time_entry_v1(
|
||||||
time_id: int,
|
time_id: int,
|
||||||
|
|||||||
35
app/timetracking/backend/timer_transaction.py
Normal file
35
app/timetracking/backend/timer_transaction.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"""Serialize opt-in remote/desktop timer writes using the same transaction helpers."""
|
||||||
|
from contextlib import nullcontext
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import atomic_transaction, _transaction_connection, execute_query
|
||||||
|
|
||||||
|
|
||||||
|
def serialized_timer(function):
|
||||||
|
@wraps(function)
|
||||||
|
async def wrapped(*args, **kwargs):
|
||||||
|
# Leave legacy behavior unchanged while remote control is disabled.
|
||||||
|
if not settings.REMOTE_ENABLED:
|
||||||
|
return await function(*args, **kwargs)
|
||||||
|
import inspect
|
||||||
|
arguments = inspect.signature(function).bind_partial(*args, **kwargs).arguments
|
||||||
|
user = arguments.get('current_user')
|
||||||
|
if not isinstance(user, dict) or not user.get('id'):
|
||||||
|
return await function(*args, **kwargs)
|
||||||
|
context = nullcontext() if _transaction_connection.get() else atomic_transaction()
|
||||||
|
with context:
|
||||||
|
ids = {int(user['id'])}
|
||||||
|
payload = arguments.get('payload') or {}
|
||||||
|
if function.__name__ in {'start_live_timer_v1', 'create_manual_time_v1'}:
|
||||||
|
raw_ids = payload.get('medarbejder_ids') or [payload.get('medarbejder_id')]
|
||||||
|
if isinstance(raw_ids, list):
|
||||||
|
for uid in raw_ids:
|
||||||
|
try:
|
||||||
|
if uid is not None:
|
||||||
|
ids.add(int(uid))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # The existing handler supplies the validation error.
|
||||||
|
execute_query('SELECT user_id FROM users WHERE user_id=ANY(%s) ORDER BY user_id FOR UPDATE', (sorted(ids),))
|
||||||
|
return await function(*args, **kwargs)
|
||||||
|
return wrapped
|
||||||
@ -14,7 +14,7 @@
|
|||||||
<div id="wf-branches"></div><div class="workflow-actions my-2"><button id="wf-add-branch" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-sm btn-outline-secondary">Tilføj betingelse</button><button id="wf-add-else" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-sm btn-outline-secondary">Tilføj ellers</button><button id="wf-save" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-primary">Gem ny version</button><button id="wf-copy" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-outline-secondary">Kopiér</button></div>
|
<div id="wf-branches"></div><div class="workflow-actions my-2"><button id="wf-add-branch" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-sm btn-outline-secondary">Tilføj betingelse</button><button id="wf-add-else" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-sm btn-outline-secondary">Tilføj ellers</button><button id="wf-save" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-primary">Gem ny version</button><button id="wf-copy" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-outline-secondary">Kopiér</button></div>
|
||||||
</fieldset><div class="workflow-actions my-2"><button id="wf-disable" {% if not wp.get("activate") %}hidden disabled{% endif %} class="btn btn-outline-danger">Deaktivér</button></div><hr><h5>Versioner og test</h5><select id="wf-versions" class="form-select mb-2"></select>
|
</fieldset><div class="workflow-actions my-2"><button id="wf-disable" {% if not wp.get("activate") %}hidden disabled{% endif %} class="btn btn-outline-danger">Deaktivér</button></div><hr><h5>Versioner og test</h5><select id="wf-versions" class="form-select mb-2"></select>
|
||||||
<div class="row g-2"><label class="col-md-3">Eksisterende data<select id="wf-entity" class="form-select"><option value="">Indtast testdata</option><option value="case">Sag</option><option value="time_entry">Tidsregistrering</option></select></label><label class="col-md-3">ID<input id="wf-entity-id" type="number" min="1" class="form-control"></label></div>
|
<div class="row g-2"><label class="col-md-3">Eksisterende data<select id="wf-entity" class="form-select"><option value="">Indtast testdata</option><option value="case">Sag</option><option value="time_entry">Tidsregistrering</option></select></label><label class="col-md-3">ID<input id="wf-entity-id" type="number" min="1" class="form-control"></label></div>
|
||||||
<div class="row g-2 mt-2"><label class="col-md-4">Testkunde<select id="wf-test-customer" class="form-select"><option value="">Ingen kunde</option></select></label><label class="col-md-2">Timer<input id="wf-test-hours" type="number" min="0" step="0.25" value="1" class="form-control"></label><label class="col-md-6">Beskrivelse<input id="wf-test-description" class="form-control" placeholder="Fx hjælp med Excel"></label></div><p class="small text-muted mt-2">Vælg en eksisterende sag eller tid ovenfor for at teste med de faktiske tags, aftaler og klippekort.</p>
|
<div class="row g-2 mt-2"><label class="col-md-4">Testkunde<select id="wf-test-customer" class="form-select"><option value="">Ingen kunde</option></select></label><label class="col-md-2">Timer<input id="wf-test-hours" type="number" min="0" step="0.25" value="1" class="form-control"></label><label class="col-md-6">Beskrivelse<input id="wf-test-description" class="form-control" placeholder="Fx hjælp med Excel"></label></div><label class="d-block mt-2">Sagstitel til manuelle testdata<input id="wf-test-case-title" class="form-control" placeholder="Fx TEST tid"></label><p class="small text-muted mt-2">Vælg en eksisterende sag eller tid ovenfor for at teste med de faktiske tags, aftaler og klippekort.</p>
|
||||||
<div class="workflow-actions my-2"><button id="wf-test" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-outline-primary">Test gemt version</button><button id="wf-activate" {% if not wp.get("activate") %}hidden disabled{% endif %} disabled class="btn btn-success">Aktivér testet version</button></div><div id="wf-test-explanation" class="my-2"></div><details><summary>Tekniske testdetaljer</summary><pre id="wf-result" class="bg-light p-2" style="max-height:350px;overflow:auto"></pre></details><h6>Aktiveringshistorik</h6><div id="wf-history"></div>
|
<div class="workflow-actions my-2"><button id="wf-test" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-outline-primary">Test gemt version</button><button id="wf-activate" {% if not wp.get("activate") %}hidden disabled{% endif %} disabled class="btn btn-success">Aktivér testet version</button></div><div id="wf-test-explanation" class="my-2"></div><details><summary>Tekniske testdetaljer</summary><pre id="wf-result" class="bg-light p-2" style="max-height:350px;overflow:auto"></pre></details><h6>Aktiveringshistorik</h6><div id="wf-history"></div>
|
||||||
</div></main></div>
|
</div></main></div>
|
||||||
</div>
|
</div>
|
||||||
@ -24,7 +24,7 @@
|
|||||||
(()=>{'use strict';
|
(()=>{'use strict';
|
||||||
const $=id=>document.getElementById('wf-'+id); let tagRows=[],customerRows=[],actionOptions={users:[],groups:[],workflows:[],agreements:[],cards:[]};let catalog={}, current=null, version=null, receipt=null, dirty=false;
|
const $=id=>document.getElementById('wf-'+id); let tagRows=[],customerRows=[],actionOptions={users:[],groups:[],workflows:[],agreements:[],cards:[]};let catalog={}, current=null, version=null, receipt=null, dirty=false;
|
||||||
let definition={trigger:'time.created',stop_after_match:false,branches:[{condition:{all:[{field:'description',operator:'contains',value:''}]},actions:[{type:'set_billing',method:'invoice'}]}]};
|
let definition={trigger:'time.created',stop_after_match:false,branches:[{condition:{all:[{field:'description',operator:'contains',value:''}]},actions:[{type:'set_billing',method:'invoice'}]}]};
|
||||||
const labels={customer_id:'Kunde',case_id:'Sag',description:'Beskrivelse',hours:'Timer',worked_date:'Arbejdsdato',tags:'Tags',tag_ids:'Tags',status:'Status',billable:'Fakturerbar',entity_type:'Modul',sla_status:'SLA-status',priority:'Prioritet',subject:'Emne',has_active_agreement:'Aktiv fastprisaftale',has_active_card:'Aktivt klippekort',eq:'Er lig med',ne:'Er ikke lig med',contains:'Indeholder',not_contains:'Indeholder ikke',in:'Er en af',not_in:'Er ingen af',gt:'Større end',gte:'Mindst',lt:'Mindre end',lte:'Højst',exists:'Findes',all:'Alle betingelser (OG)',any:'Mindst én betingelse (ELLER)',low:'Lav',normal:'Normal',high:'Høj',urgent:'Haster','time.created':'Tid oprettet','time.updated':'Tid ændret','case.created':'Sag oprettet','case.updated':'Sag ændret','tags.changed':'Tags ændret','mail.received':'Mail modtaget','sla.changed':'SLA ændret','customer.updated':'Kunde ændret','task.updated':'Opgave ændret','reminder.due':'Reminder forfalden','notification.created':'Notifikation oprettet',send_to_review:'Send til manuel vurdering',remove_tag:'Fjern automatisk tag',assign_user:'Tildel medarbejder',assign_group:'Tildel gruppe',start_workflow:'Start workflow',create_reminder:'Opret reminder',set_billing:'Vælg afregning',add_tag:'Tilføj tag',suggest_tag:'Foreslå tag',notify:'Notifikation',create_task:'Opret opgave',set_field:'Sæt felt',invoice:'Faktura',subscription:'Fastpris',prepaid:'Klippekort',internal:'Intern tid',non_billable:'Ikke fakturerbar'};
|
const labels={customer_id:'Kunde',case_id:'Sags-ID',case_title:'Sagstitel',description:'Beskrivelse',hours:'Timer',worked_date:'Arbejdsdato',tags:'Tags',tag_ids:'Tags',status:'Status',billable:'Fakturerbar',entity_type:'Modul',sla_status:'SLA-status',priority:'Prioritet',subject:'Emne',has_active_agreement:'Aktiv fastprisaftale',has_active_card:'Aktivt klippekort',eq:'Er lig med',ne:'Er ikke lig med',contains:'Indeholder',not_contains:'Indeholder ikke',in:'Er en af',not_in:'Er ingen af',gt:'Større end',gte:'Mindst',lt:'Mindre end',lte:'Højst',exists:'Findes',all:'Alle betingelser (OG)',any:'Mindst én betingelse (ELLER)',low:'Lav',normal:'Normal',high:'Høj',urgent:'Haster','time.created':'Tid oprettet','time.updated':'Tid ændret','case.created':'Sag oprettet','case.updated':'Sag ændret','tags.changed':'Tags ændret','mail.received':'Mail modtaget','sla.changed':'SLA ændret','customer.updated':'Kunde ændret','task.updated':'Opgave ændret','reminder.due':'Reminder forfalden','notification.created':'Notifikation oprettet',send_to_review:'Send til manuel vurdering',remove_tag:'Fjern automatisk tag',assign_user:'Tildel medarbejder',assign_group:'Tildel gruppe',start_workflow:'Start workflow',create_reminder:'Opret reminder',set_billing:'Vælg afregning',add_tag:'Tilføj tag',suggest_tag:'Foreslå tag',notify:'Notifikation',create_task:'Opret opgave',set_field:'Sæt felt',invoice:'Faktura',subscription:'Fastpris',prepaid:'Klippekort',internal:'Intern tid',non_billable:'Ikke fakturerbar'};
|
||||||
function message(text,error=false){$('message').textContent=text;$('message').className='alert '+(error?'alert-danger':'alert-info');}
|
function message(text,error=false){$('message').textContent=text;$('message').className='alert '+(error?'alert-danger':'alert-info');}
|
||||||
async function api(path='',body){const response=await fetch('/api/v1/workflows'+path,{method:body?'POST':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const data=await response.json();if(!response.ok)throw Error(typeof data.detail==='string'?data.detail:JSON.stringify(data.detail));return data;}
|
async function api(path='',body){const response=await fetch('/api/v1/workflows'+path,{method:body?'POST':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const data=await response.json();if(!response.ok)throw Error(typeof data.detail==='string'?data.detail:JSON.stringify(data.detail));return data;}
|
||||||
function changed(){dirty=true;receipt=null;$('activate').disabled=true;}
|
function changed(){dirty=true;receipt=null;$('activate').disabled=true;}
|
||||||
@ -32,7 +32,7 @@ function element(tag,className,text){const el=document.createElement(tag);if(cla
|
|||||||
function select(values,value,onchange){const el=element('select','form-select form-select-sm');values.forEach(v=>{const opt=element('option','',labels[v]||v);opt.value=v;el.append(opt);});el.value=value;el.onchange=()=>{onchange(el.value);changed();};return el;}
|
function select(values,value,onchange){const el=element('select','form-select form-select-sm');values.forEach(v=>{const opt=element('option','',labels[v]||v);opt.value=v;el.append(opt);});el.value=value;el.onchange=()=>{onchange(el.value);changed();};return el;}
|
||||||
function input(value,onchange){const el=element('input','form-control form-control-sm');el.value=typeof value==='string'?value:JSON.stringify(value??'');el.onchange=()=>{let v=el.value;try{v=JSON.parse(v);}catch{}onchange(v);changed();};return el;}
|
function input(value,onchange){const el=element('input','form-control form-control-sm');el.value=typeof value==='string'?value:JSON.stringify(value??'');el.onchange=()=>{let v=el.value;try{v=JSON.parse(v);}catch{}onchange(v);changed();};return el;}
|
||||||
function button(text,fn){const el=element('button','btn btn-sm btn-outline-secondary',text);el.type='button';el.onclick=()=>{fn();changed();render();};return el;}
|
function button(text,fn){const el=element('button','btn btn-sm btn-outline-secondary',text);el.type='button';el.onclick=()=>{fn();changed();render();};return el;}
|
||||||
function namedSelect(rows,value,onchange,multiple=false){const el=element('select','form-select form-select-sm');el.multiple=multiple;if(!multiple){const placeholder=element('option','','Vælg…');placeholder.value='';el.append(placeholder);}for(const row of rows){const opt=element('option','',row.label||row.name);opt.value=String(row.id);opt.selected=(Array.isArray(value)?value.map(String):[String(value)]).includes(String(row.id));el.append(opt);}el.onchange=()=>{onchange(multiple?[...el.selectedOptions].map(o=>Number(o.value)):(el.value?Number(el.value):null));changed();};return el;}function conditionValue(node){if(['tags','tag_ids'].includes(node.field)){const box=element('div','');box.append(namedSelect(tagRows,node.value,v=>node.value=v,['in','not_in'].includes(node.operator)));const label=element('label','small',' Medtag undertags');const check=element('input');check.type='checkbox';check.checked=!!node.include_descendants;check.onchange=()=>{node.include_descendants=check.checked;changed();};label.prepend(check);box.append(label);return box;}if(node.field==='customer_id')return namedSelect(customerRows,node.value,v=>node.value=v===null?null:Array.isArray(v)?v.map(String):String(v),['in','not_in'].includes(node.operator));return input(node.value,v=>node.value=v);}function readableCondition(node,host,depth=0){if(!node)return;const names={customer_id:'Kunde',description:'Beskrivelse',hours:'Timer',tags:'Tags',tag_ids:'Tags',worked_date:'Arbejdsdato',status:'Status'},operators={eq:'er',ne:'er ikke',contains:'indeholder',not_contains:'indeholder ikke',in:'er en af',not_in:'er ingen af',gt:'større end',gte:'mindst',lt:'mindre end',lte:'højst',exists:'findes'};let text;if(node.children)text=node.kind==='all'?'Alle betingelser':'Mindst én betingelse';else if(node.kind==='else')text='Ellers';else{const values=Array.isArray(node.expected)?node.expected:[node.expected];const expected=values.map(v=>['tags','tag_ids'].includes(node.field)?tagRows.find(t=>Number(t.id)===Number(v))?.label||v:node.field==='customer_id'?customerRows.find(c=>String(c.id)===String(v))?.name||v:v).join(', ');text=(names[node.field]||node.field)+' '+(operators[node.operator]||node.operator)+' '+expected;}const line=element('div','small',`${node.matched?'✓':'×'} ${text}`);line.style.paddingLeft=(depth*16)+'px';host.append(line);for(const child of node.children||[])readableCondition(child,host,depth+1);}function leaf(){return {field:'description',operator:'contains',value:''};}
|
function namedSelect(rows,value,onchange,multiple=false){const el=element('select','form-select form-select-sm');el.multiple=multiple;if(!multiple){const placeholder=element('option','','Vælg…');placeholder.value='';el.append(placeholder);}for(const row of rows){const opt=element('option','',row.label||row.name);opt.value=String(row.id);opt.selected=(Array.isArray(value)?value.map(String):[String(value)]).includes(String(row.id));el.append(opt);}el.onchange=()=>{onchange(multiple?[...el.selectedOptions].map(o=>Number(o.value)):(el.value?Number(el.value):null));changed();};return el;}function conditionValue(node){if(['tags','tag_ids'].includes(node.field)){const box=element('div','');box.append(namedSelect(tagRows,node.value,v=>node.value=v,['in','not_in'].includes(node.operator)));const label=element('label','small',' Medtag undertags');const check=element('input');check.type='checkbox';check.checked=!!node.include_descendants;check.onchange=()=>{node.include_descendants=check.checked;changed();};label.prepend(check);box.append(label);return box;}if(node.field==='customer_id')return namedSelect(customerRows,node.value,v=>node.value=v===null?null:Array.isArray(v)?v.map(String):String(v),['in','not_in'].includes(node.operator));return input(node.value,v=>node.value=v);}function readableCondition(node,host,depth=0){if(!node)return;const names={customer_id:'Kunde',description:'Beskrivelse',hours:'Timer',tags:'Tags',tag_ids:'Tags',worked_date:'Arbejdsdato',status:'Status',case_title:'Sagstitel',case_id:'Sags-ID'},operators={eq:'er',ne:'er ikke',contains:'indeholder',not_contains:'indeholder ikke',in:'er en af',not_in:'er ingen af',gt:'større end',gte:'mindst',lt:'mindre end',lte:'højst',exists:'findes'};let text;if(node.children)text=node.kind==='all'?'Alle betingelser':'Mindst én betingelse';else if(node.kind==='else')text='Ellers';else{const values=Array.isArray(node.expected)?node.expected:[node.expected];const expected=values.map(v=>['tags','tag_ids'].includes(node.field)?tagRows.find(t=>Number(t.id)===Number(v))?.label||v:node.field==='customer_id'?customerRows.find(c=>String(c.id)===String(v))?.name||v:v).join(', ');text=(names[node.field]||node.field)+' '+(operators[node.operator]||node.operator)+' '+expected+' · Testværdi: '+(node.actual==null?'mangler':Array.isArray(node.actual)?node.actual.join(', '):String(node.actual));}const line=element('div','small',`${node.matched?'✓':'×'} ${text}`);line.style.paddingLeft=(depth*16)+'px';host.append(line);for(const child of node.children||[])readableCondition(child,host,depth+1);}function leaf(){return {field:'description',operator:'contains',value:''};}
|
||||||
function condition(node,replace,remove){const box=element('div','border rounded p-2 my-1');if(node.all||node.any){const key=node.all?'all':'any';box.append(select(['all','any'],key,v=>{node[v]=node[key];if(v!==key)delete node[key];render();}));node[key].forEach((child,i)=>box.append(condition(child,v=>node[key][i]=v,()=>node[key].splice(i,1))));const controls=element('div','d-flex gap-1');controls.append(button('+ Betingelse',()=>node[key].push(leaf())),button('+ Gruppe',()=>node[key].push({all:[leaf()]})));if(remove)controls.append(button('Fjern gruppe',remove));box.append(controls);}else{const row=element('div','d-flex gap-1');row.append(select(catalog.fields,node.field,v=>{node.field=v;if(['tags','tag_ids'].includes(v))node.operator='contains';render();}),select(['tags','tag_ids'].includes(node.field)?['contains','not_contains','exists']:catalog.operators,node.operator,v=>{node.operator=v;render();}),conditionValue(node),button('Gruppér',()=>replace({all:[node]})));if(remove)row.append(button('Fjern',remove));box.append(row);}return box;}
|
function condition(node,replace,remove){const box=element('div','border rounded p-2 my-1');if(node.all||node.any){const key=node.all?'all':'any';box.append(select(['all','any'],key,v=>{node[v]=node[key];if(v!==key)delete node[key];render();}));node[key].forEach((child,i)=>box.append(condition(child,v=>node[key][i]=v,()=>node[key].splice(i,1))));const controls=element('div','d-flex gap-1');controls.append(button('+ Betingelse',()=>node[key].push(leaf())),button('+ Gruppe',()=>node[key].push({all:[leaf()]})));if(remove)controls.append(button('Fjern gruppe',remove));box.append(controls);}else{const row=element('div','d-flex gap-1');row.append(select(catalog.fields,node.field,v=>{node.field=v;if(['tags','tag_ids'].includes(v))node.operator='contains';render();}),select(['tags','tag_ids'].includes(node.field)?['contains','not_contains','exists']:catalog.operators,node.operator,v=>{node.operator=v;render();}),conditionValue(node),button('Gruppér',()=>replace({all:[node]})));if(remove)row.append(button('Fjern',remove));box.append(row);}return box;}
|
||||||
function render(){const root=$('branches');root.replaceChildren();definition.branches.forEach((branch,i)=>{const box=element('section','border rounded p-2 mb-2');box.append(element('strong','',branch.condition===null?'Ellers':i===0?'Hvis':'Ellers hvis'));if(branch.condition)box.append(condition(branch.condition,v=>branch.condition=v));branch.actions.forEach((action,j)=>{const row=element('div','d-flex gap-1 my-1');row.append(select(catalog.actions,action.type,v=>{branch.actions[j]=v==='set_billing'?{type:v,method:'invoice'}:v==='set_field'?{type:v,field:'priority',value:'normal'}:{type:v};render();}));if(action.type==='set_billing'){row.append(select(catalog.billing_methods,action.method,v=>action.method=v));if(action.method==='subscription'||action.method==='prepaid'){const key=action.method==='subscription'?'agreement_id':'card_id',pool=action.method==='subscription'?actionOptions.agreements:actionOptions.cards;const scoped=$('customer').value?pool.filter(r=>String(r.customer_id)===$('customer').value):pool;row.append(namedSelect(scoped,action[key],v=>{if(v===null)delete action[key];else action[key]=v;}));}if(action.method==='invoice'){const product=input(action.product_number||'',v=>{if(v==='')delete action.product_number;else action.product_number=v;});product.placeholder='Varenummer (valgfrit)';row.append(product);}}else if(['add_tag','remove_tag','suggest_tag'].includes(action.type)){row.append(namedSelect(tagRows,action.tag_id,v=>action.tag_id=v));}else if(action.type==='set_field'){row.append(select(['priority','status'],action.field||'priority',v=>action.field=v),input(action.value,v=>action.value=v));}else if(['assign_user','assign_group','start_workflow'].includes(action.type)){const key={assign_user:'user_id',assign_group:'group_id',start_workflow:'workflow_id'}[action.type];const pool={assign_user:actionOptions.users,assign_group:actionOptions.groups,start_workflow:actionOptions.workflows}[action.type];row.append(namedSelect(pool,action[key],v=>action[key]=v));}else if(action.type==='send_to_review'){const reason=input(action.reason||'',v=>action.reason=v);reason.placeholder='Begrundelse (valgfri)';row.append(reason);}else{const title=input(action.title||'',v=>action.title=v);title.placeholder='Titel';row.append(title);if(['notify','create_reminder'].includes(action.type)){row.append(namedSelect(actionOptions.users,action.recipient_user_ids||[],v=>action.recipient_user_ids=v,true));}if(action.type==='create_reminder'){const due=element('input','form-control form-control-sm');due.type='datetime-local';if(action.scheduled_at){const date=new Date(action.scheduled_at);if(!Number.isNaN(date.getTime()))due.value=new Date(date.getTime()-date.getTimezoneOffset()*60000).toISOString().slice(0,16);}due.onchange=()=>{if(due.value)action.scheduled_at=new Date(due.value).toISOString();else delete action.scheduled_at;changed();};row.append(due);}}row.append(button('Fjern handling',()=>branch.actions.splice(j,1)));box.append(row);});box.append(button('+ Handling',()=>branch.actions.push({type:'set_billing',method:'invoice'})));if(definition.branches.length>1)box.append(button('Fjern gren',()=>definition.branches.splice(i,1)));root.append(box);});}
|
function render(){const root=$('branches');root.replaceChildren();definition.branches.forEach((branch,i)=>{const box=element('section','border rounded p-2 mb-2');box.append(element('strong','',branch.condition===null?'Ellers':i===0?'Hvis':'Ellers hvis'));if(branch.condition)box.append(condition(branch.condition,v=>branch.condition=v));branch.actions.forEach((action,j)=>{const row=element('div','d-flex gap-1 my-1');row.append(select(catalog.actions,action.type,v=>{branch.actions[j]=v==='set_billing'?{type:v,method:'invoice'}:v==='set_field'?{type:v,field:'priority',value:'normal'}:{type:v};render();}));if(action.type==='set_billing'){row.append(select(catalog.billing_methods,action.method,v=>action.method=v));if(action.method==='subscription'||action.method==='prepaid'){const key=action.method==='subscription'?'agreement_id':'card_id',pool=action.method==='subscription'?actionOptions.agreements:actionOptions.cards;const scoped=$('customer').value?pool.filter(r=>String(r.customer_id)===$('customer').value):pool;row.append(namedSelect(scoped,action[key],v=>{if(v===null)delete action[key];else action[key]=v;}));}if(action.method==='invoice'){const product=input(action.product_number||'',v=>{if(v==='')delete action.product_number;else action.product_number=v;});product.placeholder='Varenummer (valgfrit)';row.append(product);}}else if(['add_tag','remove_tag','suggest_tag'].includes(action.type)){row.append(namedSelect(tagRows,action.tag_id,v=>action.tag_id=v));}else if(action.type==='set_field'){row.append(select(['priority','status'],action.field||'priority',v=>action.field=v),input(action.value,v=>action.value=v));}else if(['assign_user','assign_group','start_workflow'].includes(action.type)){const key={assign_user:'user_id',assign_group:'group_id',start_workflow:'workflow_id'}[action.type];const pool={assign_user:actionOptions.users,assign_group:actionOptions.groups,start_workflow:actionOptions.workflows}[action.type];row.append(namedSelect(pool,action[key],v=>action[key]=v));}else if(action.type==='send_to_review'){const reason=input(action.reason||'',v=>action.reason=v);reason.placeholder='Begrundelse (valgfri)';row.append(reason);}else{const title=input(action.title||'',v=>action.title=v);title.placeholder='Titel';row.append(title);if(['notify','create_reminder'].includes(action.type)){row.append(namedSelect(actionOptions.users,action.recipient_user_ids||[],v=>action.recipient_user_ids=v,true));}if(action.type==='create_reminder'){const due=element('input','form-control form-control-sm');due.type='datetime-local';if(action.scheduled_at){const date=new Date(action.scheduled_at);if(!Number.isNaN(date.getTime()))due.value=new Date(date.getTime()-date.getTimezoneOffset()*60000).toISOString().slice(0,16);}due.onchange=()=>{if(due.value)action.scheduled_at=new Date(due.value).toISOString();else delete action.scheduled_at;changed();};row.append(due);}}row.append(button('Fjern handling',()=>branch.actions.splice(j,1)));box.append(row);});box.append(button('+ Handling',()=>branch.actions.push({type:'set_billing',method:'invoice'})));if(definition.branches.length>1)box.append(button('Fjern gren',()=>definition.branches.splice(i,1)));root.append(box);});}
|
||||||
function loadVersion(v){version=v;definition=structuredClone(v.definition);const meta=definition.metadata||{};$('name').value=meta.name||current.name;$('customer').value=meta.customer_id??current.customer_id??'';$('priority').value=meta.priority??current.priority;$('trigger').value=definition.trigger;$('stop').checked=!!definition.stop_after_match;dirty=false;receipt=null;$('activate').disabled=true;render();}
|
function loadVersion(v){version=v;definition=structuredClone(v.definition);const meta=definition.metadata||{};$('name').value=meta.name||current.name;$('customer').value=meta.customer_id??current.customer_id??'';$('priority').value=meta.priority??current.priority;$('trigger').value=definition.trigger;$('stop').checked=!!definition.stop_after_match;dirty=false;receipt=null;$('activate').disabled=true;render();}
|
||||||
@ -45,7 +45,7 @@ $('add-branch').onclick=()=>{const index=definition.branches.findIndex(b=>b.cond
|
|||||||
$('add-else').onclick=()=>{if(!definition.branches.some(b=>b.condition===null))definition.branches.push({condition:null,actions:[{type:'set_billing',method:'invoice'}]});changed();render();};
|
$('add-else').onclick=()=>{if(!definition.branches.some(b=>b.condition===null))definition.branches.push({condition:null,actions:[{type:'set_billing',method:'invoice'}]});changed();render();};
|
||||||
$('save').onclick=()=>guard(async()=>{definition.trigger=$('trigger').value;definition.stop_after_match=$('stop').checked;const draft={name:$('name').value,customer_id:$('customer').value||null,priority:Number($('priority').value),definition};const saved=await api(current?'/'+current.id+'/versions':'',draft);await list();await open(current?current.id:saved.id);message('Ny version gemt. Test den før aktivering.');});
|
$('save').onclick=()=>guard(async()=>{definition.trigger=$('trigger').value;definition.stop_after_match=$('stop').checked;const draft={name:$('name').value,customer_id:$('customer').value||null,priority:Number($('priority').value),definition};const saved=await api(current?'/'+current.id+'/versions':'',draft);await list();await open(current?current.id:saved.id);message('Ny version gemt. Test den før aktivering.');});
|
||||||
$('versions').onchange=()=>loadVersion(current.versions.find(v=>v.id===Number($('versions').value)));
|
$('versions').onchange=()=>loadVersion(current.versions.find(v=>v.id===Number($('versions').value)));
|
||||||
$('test').onclick=()=>guard(async()=>{if(!current||dirty)throw Error('Gem ændringerne som en ny version før test');const entity=$('entity').value;const payload={version_id:version.id};if(entity){payload.entity_type=entity;payload.entity_id=Number($('entity-id').value);}else payload.context={customer_id:$('test-customer').value||null,description:$('test-description').value,hours:Number($('test-hours').value),tag_ids:[]};const data=await api('/'+current.id+'/simulate',payload);receipt=data.simulation_receipt;$('test-explanation').replaceChildren();const summary=element('p','',data.result.status==='decided'?'Afregning: '+(labels[data.result.method]||data.result.method):'Afventer manuel vurdering');$('test-explanation').append(summary);for(const trace of data.result.trace||[]){$('test-explanation').append(element('div','small fw-semibold',`Workflow #${trace.workflow_id} · gren ${trace.branch+1}: ${trace.matched?'opfyldt':'ikke opfyldt'}`));readableCondition(trace.condition,$('test-explanation'));}$('result').textContent=JSON.stringify(data.result,null,2);$('activate').disabled=false;});
|
$('test').onclick=()=>guard(async()=>{if(!current||dirty)throw Error('Gem ændringerne som en ny version før test');const entity=$('entity').value;if(!entity&&$('entity-id').value)throw Error('Du har indtastet et ID. Vælg Sag eller Tidsregistrering under Eksisterende data, så testen bruger de faktiske oplysninger.');const payload={version_id:version.id};if(entity){payload.entity_type=entity;payload.entity_id=Number($('entity-id').value);}else payload.context={customer_id:$('test-customer').value||null,description:$('test-description').value,case_title:$('test-case-title').value,hours:Number($('test-hours').value),tag_ids:[]};const data=await api('/'+current.id+'/simulate',payload);receipt=data.simulation_receipt;$('test-explanation').replaceChildren();const summary=element('p','',data.result.status==='decided'?'Afregning: '+(labels[data.result.method]||data.result.method):data.result.actions?.length?'Reglen matcher · '+data.result.actions.length+' handling(er) planlagt. Testen udfører dem ikke.':'Ingen handling matcher · '+(data.result.reason==='no_matching_rule'?'ingen matchende regel':'manuel vurdering'));$('test-explanation').append(summary);for(const trace of data.result.trace||[]){if(trace.source==='resource_coverage'){$('test-explanation').append(element('div','small',trace.matched?'Aftaler og klippekort: dækning matcher':'Aftaler og klippekort: ingen dækning matcher'));continue;}if(!Number.isInteger(trace.branch))continue;$('test-explanation').append(element('div','small fw-semibold',`Workflow #${trace.workflow_id} · gren ${trace.branch+1}: ${trace.matched?'opfyldt':'ikke opfyldt'}`));readableCondition(trace.condition,$('test-explanation'));}$('result').textContent=JSON.stringify(data.result,null,2);$('activate').disabled=false;});
|
||||||
$('activate').onclick=()=>guard(async()=>{if(!receipt||dirty)throw Error('Test versionen først');await api('/'+current.id+'/activate',{version_id:version.id,simulation_receipt:receipt});await list();await open(current.id);message('Den valgte version er aktiveret. Motorens featureflag styrer behandling.');});
|
$('activate').onclick=()=>guard(async()=>{if(!receipt||dirty)throw Error('Test versionen først');await api('/'+current.id+'/activate',{version_id:version.id,simulation_receipt:receipt});await list();await open(current.id);message('Den valgte version er aktiveret. Motorens featureflag styrer behandling.');});
|
||||||
$('disable').onclick=()=>guard(async()=>{if(!current)throw Error('Vælg et workflow');await api('/'+current.id+'/disable',{});await list();await open(current.id);message('Workflow deaktiveret.');});
|
$('disable').onclick=()=>guard(async()=>{if(!current)throw Error('Vælg et workflow');await api('/'+current.id+'/disable',{});await list();await open(current.id);message('Workflow deaktiveret.');});
|
||||||
$('copy').onclick=()=>guard(async()=>{if(!current)throw Error('Gem workflowet først');const row=await api('/'+current.id+'/copy',{name:($('name').value||current.name)+' – kopi'});await list();await open(row.id);});
|
$('copy').onclick=()=>guard(async()=>{if(!current)throw Error('Gem workflowet først');const row=await api('/'+current.id+'/copy',{name:($('name').value||current.name)+' – kopi'});await list();await open(row.id);});
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
async function api(path,body){const r=await fetch('/api/v1/workflows'+path,{method:body?'PUT':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const d=await r.json();if(!r.ok)throw Error(typeof d.detail==='string'?d.detail:JSON.stringify(d.detail));return d;}
|
async function api(path,body){const r=await fetch('/api/v1/workflows'+path,{method:body?'PUT':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const d=await r.json();if(!r.ok)throw Error(typeof d.detail==='string'?d.detail:JSON.stringify(d.detail));return d;}
|
||||||
function el(tag,text,cls){const e=document.createElement(tag);if(text)e.textContent=text;if(cls)e.className=cls;return e;}
|
function el(tag,text,cls){const e=document.createElement(tag);if(text)e.textContent=text;if(cls)e.className=cls;return e;}
|
||||||
function choices(selected){const e=el('select','','form-select');e.multiple=true;e.size=6;for(const t of tags){const o=el('option',t.label||t.name);o.value=t.id;o.selected=(selected||[]).includes(t.id);e.append(o);}return e;}
|
function choices(selected){const e=el('select','','form-select');e.multiple=true;e.size=6;for(const t of tags){const o=el('option',t.label||t.name);o.value=t.id;o.selected=(selected||[]).includes(t.id);e.append(o);}return e;}
|
||||||
async function load(){const data=await api('/resources'+($('customer').value?'?customer_id='+encodeURIComponent($('customer').value):''));$('list').replaceChildren();if(!data.agreements.length&&!data.cards.length)$('list').append(el('div','Ingen aftaler eller klippekort for det valgte firma.','workflow-empty'));for(const [kind,rows] of [['agreements',data.agreements],['cards',data.cards]])for(const row of rows){const coverage=row.workflow_coverage||{},card=el('section','','card p-3 my-3'),name=customers.find(c=>Number(c.id)===Number(row.customer_id))?.name||'Ukendt kunde';card.append(el('h5',`${kind==='agreements'?'Fastpris':'Klippekort'} ${row.agreement_number||row.card_number||row.id} · ${name}`),el('p','Status: '+row.status,'small text-muted'));const include=choices(coverage.tag_ids),exclude=choices(coverage.excluded_tag_ids),grid=el('div','','row g-2');for(const [title,control] of [['Dækker et af disse tags · tomt betyder alle',include],['Udelukker disse tags',exclude]]){const label=el('label',title,'col-md-6');label.append(control);grid.append(label);}card.append(grid);const label=el('label',' Medtag undertags','my-2'),descendants=el('input');descendants.type='checkbox';descendants.checked=!!coverage.include_descendants;label.prepend(descendants);card.append(label);let price=null;if(kind==='agreements'){const label=el('label','Månedspris (tom = eksisterende beregning)','d-block');price=el('input','','form-control');price.type='number';price.min='0';price.step='0.01';price.value=row.monthly_price??'';label.append(price);card.append(label);}const save=el('button','Gem dækning','btn btn-primary mt-2');save.onclick=()=>run(async()=>{const payload={workflow_coverage:{tag_ids:[...include.selectedOptions].map(o=>Number(o.value)),excluded_tag_ids:[...exclude.selectedOptions].map(o=>Number(o.value)),include_descendants:descendants.checked}};if(price)payload.monthly_price=price.value===''?null:Number(price.value);await api(`/resources/${kind}/${row.id}/coverage`,payload);$('message').className='alert alert-success';$('message').textContent='Dækningen er gemt og bruges automatisk ved afregningsvalg, når workflows er aktive for kunden. Ændringen er gemt i historikken.';await load();});card.append(save);$('list').append(card);}}
|
async function load(initialData){const data=initialData||await api('/resources'+($('customer').value?'?customer_id='+encodeURIComponent($('customer').value):''));$('list').replaceChildren();if(!data.agreements.length&&!data.cards.length)$('list').append(el('div','Ingen aftaler eller klippekort for det valgte firma.','workflow-empty'));for(const [kind,rows] of [['agreements',data.agreements],['cards',[...data.cards].sort((a,b)=>(a.status==='active'?0:1)-(b.status==='active'?0:1)||Number(b.id)-Number(a.id))]])for(const row of rows.filter((r,i,all)=>kind!=='cards'||i===all.findIndex(x=>Number(x.customer_id)===Number(r.customer_id)))){const coverage=row.workflow_coverage||{},card=el('section','','card p-3 my-3'),name=customers.find(c=>Number(c.id)===Number(row.customer_id))?.name||'Ukendt kunde';card.append(el('h5',`${kind==='agreements'?'Fastpris '+(row.agreement_number||row.id):'Klippekort · fælles tagdækning'} · ${name}`),el('p',kind==='cards'?'Gælder kundens nuværende og fremtidige klippekort. Saldo og afregning følger det aktive kort.':'Status: '+row.status,'small text-muted'));const include=choices(coverage.tag_ids),exclude=choices(coverage.excluded_tag_ids),grid=el('div','','row g-2');for(const [title,control] of [['Dækker et af disse tags · tomt betyder alle',include],['Udelukker disse tags',exclude]]){const label=el('label',title,'col-md-6');label.append(control);grid.append(label);}card.append(grid);const label=el('label',' Medtag undertags','my-2'),descendants=el('input');descendants.type='checkbox';descendants.checked=!!coverage.include_descendants;label.prepend(descendants);card.append(label);let price=null;if(kind==='agreements'){const label=el('label','Månedspris (tom = eksisterende beregning)','d-block');price=el('input','','form-control');price.type='number';price.min='0';price.step='0.01';price.value=row.monthly_price??'';label.append(price);card.append(label);}const save=el('button','Gem dækning','btn btn-primary mt-2');save.onclick=()=>run(async()=>{const payload={workflow_coverage:{tag_ids:[...include.selectedOptions].map(o=>Number(o.value)),excluded_tag_ids:[...exclude.selectedOptions].map(o=>Number(o.value)),include_descendants:descendants.checked}};if(price)payload.monthly_price=price.value===''?null:Number(price.value);if(kind==='cards')await api(`/resources/customers/${row.customer_id}/card-coverage`,payload.workflow_coverage);else await api(`/resources/${kind}/${row.id}/coverage`,payload);$('message').className='alert alert-success';$('message').textContent='Dækningen er gemt og bruges automatisk ved afregningsvalg, når workflows er aktive for kunden. Ændringen er gemt i historikken.';await load();});card.append(save);$('list').append(card);}}
|
||||||
let requestBusy=false;async function run(fn){if(requestBusy)return;requestBusy=true;document.querySelector('.workflow-page').setAttribute('aria-busy','true');try{await fn();}catch(e){$('message').className='alert alert-danger';$('message').textContent=e.message;}finally{requestBusy=false;document.querySelector('.workflow-page').setAttribute('aria-busy','false');}}
|
let requestBusy=false;async function run(fn){if(requestBusy)return;requestBusy=true;document.querySelector('.workflow-page').setAttribute('aria-busy','true');try{await fn();}catch(e){$('message').className='alert alert-danger';$('message').textContent=e.message;}finally{requestBusy=false;document.querySelector('.workflow-page').setAttribute('aria-busy','false');}}
|
||||||
$('customer').onchange=()=>run(load);run(async()=>{customers=await api('/customers');tags=await api('/tags');const map=new Map(tags.map(t=>[t.id,t]));function path(t,seen=new Set()){if(seen.has(t.id))return t.name;seen.add(t.id);const p=map.get(t.parent_id);return p?path(p,seen)+' › '+t.name:t.name;}tags=tags.map(t=>({...t,label:path(t)}));customers.forEach(c=>{const o=el('option',c.name);o.value=c.id;$('customer').append(o);});await load();});
|
$('customer').onchange=()=>run(load);run(async()=>{customers=await api('/customers');tags=await api('/tags');const map=new Map(tags.map(t=>[t.id,t]));function path(t,seen=new Set()){if(seen.has(t.id))return t.name;seen.add(t.id);const p=map.get(t.parent_id);return p?path(p,seen)+' › '+t.name:t.name;}tags=tags.map(t=>({...t,label:path(t)}));const resources=await api('/resources');const customerIds=new Set([...resources.agreements,...resources.cards].map(row=>Number(row.customer_id)));customers.filter(c=>customerIds.has(Number(c.id))).forEach(c=>{const o=el('option',c.name);o.value=c.id;$('customer').append(o);});await load(resources);});
|
||||||
})();</script>{% endblock %}
|
})();</script>{% endblock %}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
METHODS = {'invoice', 'subscription', 'prepaid', 'internal', 'non_billable'}
|
METHODS = {'invoice', 'subscription', 'prepaid', 'internal', 'non_billable'}
|
||||||
FIELDS = {'customer_id', 'case_id', 'description', 'hours', 'worked_date', 'tags', 'tag_ids', 'status', 'billable', 'entity_type', 'sla_status', 'priority', 'subject', 'has_active_agreement', 'has_active_card'}
|
FIELDS = {'customer_id', 'case_id', 'description', 'hours', 'worked_date', 'tags', 'tag_ids', 'status', 'billable', 'entity_type', 'sla_status', 'priority', 'subject', 'has_active_agreement', 'has_active_card'}
|
||||||
FIELDS.update({'remaining_card_hours','has_available_card'})
|
FIELDS.update({'remaining_card_hours','has_available_card','case_title'})
|
||||||
OPERATORS = {'eq', 'ne', 'contains', 'not_contains', 'in', 'not_in', 'gt', 'gte', 'lt', 'lte', 'exists'}
|
OPERATORS = {'eq', 'ne', 'contains', 'not_contains', 'in', 'not_in', 'gt', 'gte', 'lt', 'lte', 'exists'}
|
||||||
TRIGGERS = {'time.created', 'time.updated', 'case.created', 'case.updated', 'tags.changed', 'mail.received', 'sla.changed', 'customer.updated', 'task.updated', 'reminder.due', 'notification.created'}
|
TRIGGERS = {'time.created', 'time.updated', 'case.created', 'case.updated', 'tags.changed', 'mail.received', 'sla.changed', 'customer.updated', 'task.updated', 'reminder.due', 'notification.created'}
|
||||||
TRIGGERS.update({'case.closed','tags.added','tags.removed','customer.created','task.created'})
|
TRIGGERS.update({'case.closed','tags.added','tags.removed','customer.created','task.created'})
|
||||||
|
|||||||
@ -27,7 +27,14 @@ def list_resources(customer_id:Optional[int]=None,user=Depends(access)):
|
|||||||
cursor.execute('SELECT id,agreement_number,customer_id,status,workflow_coverage,monthly_price FROM customer_fixed_price_agreements'+where+' ORDER BY customer_id,id',params)
|
cursor.execute('SELECT id,agreement_number,customer_id,status,workflow_coverage,monthly_price FROM customer_fixed_price_agreements'+where+' ORDER BY customer_id,id',params)
|
||||||
agreements=cursor.fetchall()
|
agreements=cursor.fetchall()
|
||||||
cursor.execute('SELECT id,card_number,customer_id,status,remaining_hours,workflow_coverage FROM tticket_prepaid_cards'+where+' ORDER BY customer_id,id',params)
|
cursor.execute('SELECT id,card_number,customer_id,status,remaining_hours,workflow_coverage FROM tticket_prepaid_cards'+where+' ORDER BY customer_id,id',params)
|
||||||
return json_safe({'agreements':agreements,'cards':cursor.fetchall()})
|
cards=cursor.fetchall()
|
||||||
|
cursor.execute('SELECT customer_id,workflow_coverage FROM bmc_workflow_customer_card_coverage')
|
||||||
|
profiles={int(p['customer_id']):p['workflow_coverage'] for p in cursor.fetchall()}
|
||||||
|
for card in cards:
|
||||||
|
if int(card['customer_id']) in profiles:
|
||||||
|
card['workflow_coverage']=profiles[int(card['customer_id'])]
|
||||||
|
card['coverage_scope']='customer'
|
||||||
|
return json_safe({'agreements':agreements,'cards':cards})
|
||||||
|
|
||||||
def update_resource(kind,resource_id,payload,user):
|
def update_resource(kind,resource_id,payload,user):
|
||||||
table='customer_fixed_price_agreements' if kind=='agreement' else 'tticket_prepaid_cards'
|
table='customer_fixed_price_agreements' if kind=='agreement' else 'tticket_prepaid_cards'
|
||||||
@ -69,3 +76,31 @@ def update_agreement(resource_id:int,payload:ResourceUpdate,user=Depends(access)
|
|||||||
@router.put('/cards/{resource_id}/coverage')
|
@router.put('/cards/{resource_id}/coverage')
|
||||||
def update_card(resource_id:int,payload:ResourceUpdate,user=Depends(access)):
|
def update_card(resource_id:int,payload:ResourceUpdate,user=Depends(access)):
|
||||||
return update_resource('card',resource_id,payload,user)
|
return update_resource('card',resource_id,payload,user)
|
||||||
|
|
||||||
|
@router.put('/customers/{customer_id}/card-coverage')
|
||||||
|
def update_customer_card_coverage(customer_id:int,payload:Coverage,user=Depends(access)):
|
||||||
|
coverage=payload.model_dump()
|
||||||
|
coverage['auto_billing']=True
|
||||||
|
tag_ids=sorted(set(coverage['tag_ids']+coverage['excluded_tag_ids']))
|
||||||
|
if any(tag_id<=0 for tag_id in tag_ids):
|
||||||
|
raise HTTPException(422,'Tag-ID skal være positivt')
|
||||||
|
with transaction() as cursor:
|
||||||
|
cursor.execute('SELECT id FROM customers WHERE id=%s',(customer_id,))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
raise HTTPException(404,'Kunden findes ikke')
|
||||||
|
if tag_ids:
|
||||||
|
cursor.execute('SELECT id FROM tags WHERE id=ANY(%s) AND is_active=TRUE',(tag_ids,))
|
||||||
|
if {r['id'] for r in cursor.fetchall()}!=set(tag_ids):
|
||||||
|
raise HTTPException(422,'Et tag findes ikke eller er inaktivt')
|
||||||
|
# Match settlement lock order. Approved decisions are immutable and revalidated at settlement.
|
||||||
|
cursor.execute("SELECT t.id FROM tmodule_times t LEFT JOIN sag_sager s ON s.id=t.sag_id LEFT JOIN tmodule_customers c ON c.id=t.customer_id WHERE COALESCE(s.customer_id,c.hub_customer_id)=%s AND t.status NOT IN ('approved','billed') AND t.economy_order_draft_id IS NULL ORDER BY t.id FOR UPDATE OF t",(customer_id,))
|
||||||
|
time_ids=[r['id'] for r in cursor.fetchall()]
|
||||||
|
cursor.execute('SELECT id FROM customers WHERE id=%s FOR UPDATE',(customer_id,))
|
||||||
|
cursor.execute('SELECT workflow_coverage FROM bmc_workflow_customer_card_coverage WHERE customer_id=%s',(customer_id,))
|
||||||
|
previous=cursor.fetchone()
|
||||||
|
cursor.execute('INSERT INTO bmc_workflow_customer_card_coverage(customer_id,workflow_coverage) VALUES(%s,%s) ON CONFLICT(customer_id) DO UPDATE SET workflow_coverage=EXCLUDED.workflow_coverage,updated_at=now()', (customer_id,Json(coverage)))
|
||||||
|
cursor.execute('INSERT INTO bmc_workflow_resource_history(resource_type,resource_id,created_by,before_value,after_value) VALUES(%s,%s,%s,%s,%s)',('customer_card',customer_id,str(user['id']),Json(json_safe(dict(previous))) if previous else Json({}),Json({'workflow_coverage':coverage})))
|
||||||
|
if time_ids:
|
||||||
|
cursor.execute('UPDATE tmodule_times SET workflow_revision=workflow_revision+1 WHERE id=ANY(%s) RETURNING id,workflow_revision',(time_ids,))
|
||||||
|
for time in cursor.fetchall(): emit_event(cursor,'time.updated','time_entry',time['id'],time['workflow_revision'])
|
||||||
|
return {'customer_id':customer_id,'workflow_coverage':coverage}
|
||||||
|
|||||||
@ -55,7 +55,7 @@ def load_time_context(time_id, cursor=None):
|
|||||||
if row['customer_id']:
|
if row['customer_id']:
|
||||||
cursor.execute("SELECT * FROM customer_fixed_price_agreements WHERE customer_id=%s AND status IN ('active','pending_cancellation') AND start_date<=COALESCE(%s::date,CURRENT_DATE) AND (end_date IS NULL OR end_date>=COALESCE(%s::date,CURRENT_DATE)) AND (cancellation_effective_date IS NULL OR cancellation_effective_date>COALESCE(%s::date,CURRENT_DATE))", (int(row['customer_id']),row.get('worked_date'),row.get('worked_date'),row.get('worked_date')))
|
cursor.execute("SELECT * FROM customer_fixed_price_agreements WHERE customer_id=%s AND status IN ('active','pending_cancellation') AND start_date<=COALESCE(%s::date,CURRENT_DATE) AND (end_date IS NULL OR end_date>=COALESCE(%s::date,CURRENT_DATE)) AND (cancellation_effective_date IS NULL OR cancellation_effective_date>COALESCE(%s::date,CURRENT_DATE))", (int(row['customer_id']),row.get('worked_date'),row.get('worked_date'),row.get('worked_date')))
|
||||||
row['agreements']=cursor.fetchall()
|
row['agreements']=cursor.fetchall()
|
||||||
cursor.execute("SELECT * FROM tticket_prepaid_cards WHERE customer_id=%s AND status='active' AND (expires_at IS NULL OR expires_at>now())",(int(row['customer_id']),))
|
cursor.execute("SELECT c.*,COALESCE(p.workflow_coverage,c.workflow_coverage) AS workflow_coverage FROM tticket_prepaid_cards c LEFT JOIN bmc_workflow_customer_card_coverage p ON p.customer_id=c.customer_id WHERE c.customer_id=%s AND c.status='active' AND (c.expires_at IS NULL OR c.expires_at>now())",(int(row['customer_id']),))
|
||||||
row['cards']=cursor.fetchall()
|
row['cards']=cursor.fetchall()
|
||||||
else:
|
else:
|
||||||
row['agreements'],row['cards']=[],[]
|
row['agreements'],row['cards']=[],[]
|
||||||
@ -71,7 +71,7 @@ def load_case_context(case_id, cursor=None):
|
|||||||
cursor.execute('SELECT * FROM sag_sager WHERE id=%s',(case_id,))
|
cursor.execute('SELECT * FROM sag_sager WHERE id=%s',(case_id,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if not row: raise ValueError('Case not found')
|
if not row: raise ValueError('Case not found')
|
||||||
context=dict(row,entity_type='case',case_id=case_id,description=row.get('beskrivelse'),subject=row.get('titel'))
|
context=dict(row,entity_type='case',case_id=case_id,case_title=row.get('titel'),description=row.get('beskrivelse'),subject=row.get('titel'))
|
||||||
context['customer_id']=str(row['customer_id']) if row.get('customer_id') is not None else None
|
context['customer_id']=str(row['customer_id']) if row.get('customer_id') is not None else None
|
||||||
cursor.execute("SELECT tag_id FROM entity_tags WHERE entity_type='case' AND entity_id=%s",(case_id,))
|
cursor.execute("SELECT tag_id FROM entity_tags WHERE entity_type='case' AND entity_id=%s",(case_id,))
|
||||||
concrete=[r['tag_id'] for r in cursor.fetchall()]
|
concrete=[r['tag_id'] for r in cursor.fetchall()]
|
||||||
|
|||||||
@ -88,7 +88,7 @@ def settle_workflow_entries(ids, actor_id, line_mode='separate', review_validato
|
|||||||
if hours <= 0:
|
if hours <= 0:
|
||||||
raise HTTPException(409, 'Tidsregistreringen skal have positiv tid')
|
raise HTTPException(409, 'Tidsregistreringen skal have positiv tid')
|
||||||
if method=='prepaid':
|
if method=='prepaid':
|
||||||
card = execute_query_single("SELECT * FROM tticket_prepaid_cards WHERE id=%s AND status='active' AND (expires_at IS NULL OR expires_at>=CURRENT_DATE)", (row.get('prepaid_card_id'),))
|
card = execute_query_single("SELECT c.*,COALESCE(p.workflow_coverage,c.workflow_coverage) AS workflow_coverage FROM tticket_prepaid_cards c LEFT JOIN bmc_workflow_customer_card_coverage p ON p.customer_id=c.customer_id WHERE c.id=%s AND c.status='active' AND (c.expires_at IS NULL OR c.expires_at>=CURRENT_DATE)", (row.get('prepaid_card_id'),))
|
||||||
if not card or int(card['customer_id']) != int(row.get('billing_customer_id') or 0):
|
if not card or int(card['customer_id']) != int(row.get('billing_customer_id') or 0):
|
||||||
raise HTTPException(409, 'Klippekortet er ikke gyldigt for kunden')
|
raise HTTPException(409, 'Klippekortet er ikke gyldigt for kunden')
|
||||||
_check_coverage(card, decisions[tid])
|
_check_coverage(card, decisions[tid])
|
||||||
|
|||||||
61
docs/remote-control.md
Normal file
61
docs/remote-control.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Fjernbetjening til BMC Hub
|
||||||
|
|
||||||
|
Implementeret lokalt. Funktionen er slået fra som standard og er ikke aktiveret i produktion.
|
||||||
|
|
||||||
|
## Aktivering og pilot
|
||||||
|
|
||||||
|
1. Tag den normale databasebackup. Anvend kun migration `1069_remote_control.sql` gennem migrationscentret eller `scripts/run_migrations.py migrations/1069_remote_control.sql --stop-on-error`. Den opretter nye tabeller og ændrer ikke eksisterende sager eller tidsregistreringer.
|
||||||
|
2. Konfigurér `REMOTE_LOOKUP_KEY` med en særskilt, kryptografisk tilfældig hemmelighed på mindst 32 tegn i serverens miljø. Gem ikke nøglen i Git. Nøglen beskytter kodeopslag, PIN og anonymiserede loginbegrænsninger. Udskiftning kræver nye koder/PIN til brugerne.
|
||||||
|
3. Sæt `REMOTE_ENABLED=true` og genstart applikationen. Hold test og produktion adskilt. Fjernbetjeningen bruger samme server og port som Hub: `http://172.16.31.183:8001/remote`. HTTP understøttes til lokalnet/VPN; der kræves ingen separat Docker eller port.
|
||||||
|
4. Hvis der bruges reverse proxy, skal den bevare Host og videregive korrekt scheme. Uvicorn må kun stole på forwarded headers fra den konkrete proxy. Begræns netværksadgang til lokalnet/VPN i eksisterende firewall/proxy; modulet åbner ingen internetforbindelse eller ekstern tunnel.
|
||||||
|
5. Pilotbrugeren logger normalt ind i Hub, vælger **Fjernbetjening**, opretter en 4-cifret PIN og en navngivet fjernbetjening. Gem den 12-cifrede kode, som kun vises ved oprettelse/udskiftning. Koden bevarer indledende nuller.
|
||||||
|
6. Åbn `/remote` på mobil/tablet. Forbind med kode + PIN. Enhedens forbindelse udløber efter 30 dage; PIN kræves igen hver ny kalenderdag i København. Tilføj siden til hjemmeskærmen via browserens menu; ingen offlinehandlinger eller service-worker-cache anvendes.
|
||||||
|
7. I én Hub-fane vælges **Følg i denne fane**. En ny valgt fane overtager. Mobilen virker også uden en computer. Der synkroniseres hvert tredje sekund på synlige sider.
|
||||||
|
|
||||||
|
## Adfærd
|
||||||
|
|
||||||
|
- Timer, pause, genoptagelse, stop og manuel tid bruger de eksisterende handlers og regler. Stop kræver beskrivelse. Manuel tid gemmes på den valgte sag med dagens danske dato og automatisk afregningsvalg som Hubs hurtigregistrering. Der godkendes eller faktureres ikke automatisk fra fjernbetjeningen.
|
||||||
|
- Dagens tid tæller gemte faktiske minutter; kørende og pausede timere tælles ikke med.
|
||||||
|
- **Nye** betyder status `Ny`. **Mine** bruger Hubs konfiguration af lukkede statusser. Søgning understøtter nummer, titel og kundenavn; visningen er pagineret med 50 sager.
|
||||||
|
- Deadline vises med lokal tidszone: gul ved højst 60 minutter tilbage, rød efter overskridelse. En deadline uden offset fortolkes i `Europe/Copenhagen`. Ingen SLA-arbejdstidsregler beregnes.
|
||||||
|
- Almindelige beskeder markeres kun som læst på brugerens tryk. Beskeder med krav om bekræftelse bruger den eksisterende separate bekræftelsesfunktion.
|
||||||
|
- Ved forbindelsesbrud fortsætter serverens timer, mens mobilens handlinger deaktiveres. Ved tabt svar kan brugeren kontrollere resultatet eller eksplicit prøve samme handling igen med samme idempotensnøgle. Intet afspilles automatisk efter genforbindelse.
|
||||||
|
- Hvis Hub ændres samtidig, returneres konflikt, og mobilen henter aktuel tilstand. Sagsvalg til en afbrudt computer leveres ikke senere. En fane med igangværende redigering spørger før navigation.
|
||||||
|
|
||||||
|
## Sikkerhed og API
|
||||||
|
|
||||||
|
`/api/v1/remote` har en separat enhedscookie og giver aldrig almindelig Hub-/administratorlogin. Administration og faneforbindelse kræver almindeligt Hub-login. Alle kald kræver featureflag; cookies er Secure ved HTTPS og kan bruges over HTTP på lokalnettet. HTTP krypterer ikke kode, PIN eller session under transport. Alle skrivninger kræver samme Origin og CSRF-token fra `/bootstrap`.
|
||||||
|
|
||||||
|
- `GET /bootstrap`, `POST /connect`, `/unlock`, `/logout`: forbindelse og daglig oplåsning.
|
||||||
|
- `GET /state`, `/cases`, `/command-result/{request_id}` og `POST /command`: begrænsede remote-funktioner. Kommandoer kræver UUID, remote-version og timerfingeraftryk. Ukendte felter afvises.
|
||||||
|
- `GET /admin`, `PUT /admin/pin`, `POST /admin/remotes`, `POST /admin/remotes/{id}/rotate`, `DELETE /admin/remotes/{id}`, `DELETE /admin/devices/{id}`: kun ejerens administration.
|
||||||
|
- `POST /follow`: valgt Hub-fane, heartbeat og kortvarigt sagsvalg.
|
||||||
|
|
||||||
|
Loginforsøg reserverer 30 sekunder i PostgreSQL før legitimationskontrollen. Begrænsningen deles mellem processer og gælder klient-IP samt kendt bruger/enhed. Forkerte forsøg rulles ikke tilbage. PIN hashes med nøglebeskyttet forbehandling; koder hashes separat. Sessiontokens hashes og gemmes i Secure/HttpOnly/SameSite=Strict-cookies. Rettigheder og brugerens aktive status kontrolleres løbende. Alle remote-svar har `Cache-Control: no-store`.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
Enhedstest: `./.venv/bin/python -m pytest tests/test_remote_control.py -q`.
|
||||||
|
|
||||||
|
Databaseintegration: `BMC_REMOTE_DB_TESTS=1 ./.venv/bin/python -m pytest tests/test_remote_control.py -q`. Kræver lokal PostgreSQL med eksisterende tidsmodul-schema; testen opretter og fjerner kun sit eget tilfældigt navngivne schema. Det offentlige schema ændres ikke. Tests dækker loginbegrænsning med samtidige forbindelser, sessionsadskillelse, PIN, tilbagekaldelse, timere, idempotens, konflikter, sagsfiltre og valgt fane.
|
||||||
|
|
||||||
|
Hubs databasepool er gjort trådsikker uden ændring af forbindelsesgrænser eller API-kontrakter, så samtidige synkrone remote- og Hub-kald kan bruge den sikkert.
|
||||||
|
|
||||||
|
Browser: `BMC_PLAYWRIGHT_MODULE=/path/to/playwright node tests/browser/remote_control.cjs`. Brug `BMC_REMOTE_BROWSER=webkit` for Safari/WebKit-motoren. Tests bruger udelukkende mockdata og mobil/tabletstørrelser. En fysisk iPhone/iPad-pilot skal desuden kontrollere lokalnet/VPN og Tilføj til hjemmeskærm.
|
||||||
|
|
||||||
|
Verificeret lokalt 13. oktober 2026: 23 remote-tests med isoleret PostgreSQL og 37 eksisterende timer-/registrerings-/afregningstests bestod. Regressionstestene blev kørt med `WORKFLOWS_ENABLED=false WORKFLOWS_MODE=shadow`, som de eksisterende tests forudsætter. Chrome og Safari/WebKit bestod mobil (390 px), tablet (820 px), administration og synkronisering med kun den valgte Hub-fane. JavaScript/Python-syntaks og `git diff --check` bestod. Produktionsaktivering og fysisk enhedstest over VPN er ikke udført.
|
||||||
|
|
||||||
|
## Tilbagerulning
|
||||||
|
|
||||||
|
Sæt `REMOTE_ENABLED=false` og genstart. Remote-ruter returnerer 404, og eksisterende Hub-adfærd fortsætter. Bevar de additive tabeller og alle tidsregistreringer. Hvis forbindelser skal være permanent tilbagekaldt før en senere genaktivering, tilbagekald dem i administrationen før deaktivering eller sæt `revoked=true` på remote-enhederne gennem en særskilt, godkendt driftsændring. Slet ikke sager eller tidsregistreringer.
|
||||||
|
|
||||||
|
|
||||||
|
## Kontakter og VoIP
|
||||||
|
|
||||||
|
Fjernbetjeningen kan søge aktive kontakter efter navn, firma, telefon og e-mail med Hub'ens eksisterende kontaktopslag. Det kræver `customers.view` (eller superadministrator), samme adgang som kundeoplysninger. Numrene vælges fra kontaktens telefon/mobil; fjernbetjeningen kan ikke vælge andre brugeres telefon eller angive en Action URL.
|
||||||
|
|
||||||
|
Ring-knappen bruger Hub'ens eksisterende click-to-call med den tilknyttede brugers VoIP-/Yealink-telefon og gældende netværkskontrol. Telefonopsætning og click-to-call skal være aktiveret i Hub. Samtalens lyd foregår på VoIP-telefonen, ikke i mobilbrowseren. Remote returnerer aldrig Action URL eller telefonens adgangsoplysninger.
|
||||||
|
|
||||||
|
Indgående ringende opkald vises fra Mission Controls fælles opkaldstilstand med kontakt, firma, nummer og modtagende telefoner. Visningen opdateres hvert tredje sekund, mens siden er synlig; forældede opkald skjules efter den eksisterende ringegrænse. Det er Hub'ens fælles indgående visning. En fejl i telefonivisningen stopper ikke timeren.
|
||||||
|
|
||||||
|
Opkald får en idempotensnøgle, der reserveres før kommandoen sendes til telefonen. Gentagelse med samme nøgle ringer ikke igen. Ved timeout eller tabt svar kan brugeren læse status og kontrollere telefonen; et gammelt opkald sendes aldrig automatisk efter genforbindelse. Et udfald efter reservation kan efterlade ukendt status, fordi telefonens eksterne handling ikke kan indgå i databasens transaktion.
|
||||||
11
main.py
11
main.py
@ -383,6 +383,14 @@ app.add_middleware(
|
|||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def auth_middleware(request: Request, call_next):
|
async def auth_middleware(request: Request, call_next):
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
# Remote endpoints validate their own restricted device session. Never turn
|
||||||
|
# a remote cookie into a normal Hub login; admin routes still require Hub auth.
|
||||||
|
if path == "/remote" or path.startswith("/remote/") or path.startswith("/api/v1/remote/"):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
response.headers["Referrer-Policy"] = "no-referrer"
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
return response
|
||||||
mission_path = path.startswith("/dashboard/mission-control") or path.startswith("/api/v1/mission/")
|
mission_path = path.startswith("/dashboard/mission-control") or path.startswith("/api/v1/mission/")
|
||||||
mission_pin = _get_mission_access_pin() if mission_path else ""
|
mission_pin = _get_mission_access_pin() if mission_path else ""
|
||||||
|
|
||||||
@ -561,6 +569,9 @@ app.include_router(also_api.router, prefix="/api/v1", tags=["ALSO Cloud Billing"
|
|||||||
app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"])
|
app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"])
|
||||||
app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bottom Bar"])
|
app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bottom Bar"])
|
||||||
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
|
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
|
||||||
|
from app.modules.remote.router import router as remote_api, views as remote_views
|
||||||
|
app.include_router(remote_api, tags=["Remote Control"])
|
||||||
|
app.include_router(remote_views, tags=["Remote Control"])
|
||||||
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
|
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
|
||||||
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
|
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
|
||||||
app.include_router(drift_api, prefix="/api/v1", tags=["Drift"])
|
app.include_router(drift_api, prefix="/api/v1", tags=["Drift"])
|
||||||
|
|||||||
6
migrations/1068_workflow_customer_card_coverage.sql
Normal file
6
migrations/1068_workflow_customer_card_coverage.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
-- Additive customer-level coverage; no balances, invoices or existing coverage are changed.
|
||||||
|
CREATE TABLE IF NOT EXISTS bmc_workflow_customer_card_coverage (
|
||||||
|
customer_id BIGINT PRIMARY KEY,
|
||||||
|
workflow_coverage JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
49
migrations/1069_remote_control.sql
Normal file
49
migrations/1069_remote_control.sql
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
-- Additive remote-control storage; existing cases and time entries are untouched.
|
||||||
|
CREATE TABLE IF NOT EXISTS remote_pins (
|
||||||
|
user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
pin_hash TEXT NOT NULL,
|
||||||
|
generation INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS remote_controls (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
code_lookup CHAR(64) UNIQUE NOT NULL,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS remote_devices (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
remote_id UUID NOT NULL REFERENCES remote_controls(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
token_hash CHAR(64) UNIQUE NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
unlocked_date DATE NOT NULL,
|
||||||
|
pin_generation INTEGER NOT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS remote_devices_remote ON remote_devices(remote_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS remote_attempts (
|
||||||
|
bucket CHAR(64) PRIMARY KEY,
|
||||||
|
next_allowed_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS remote_user_state (
|
||||||
|
user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
active_case_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
|
||||||
|
follower_id UUID,
|
||||||
|
follower_seen_at TIMESTAMPTZ,
|
||||||
|
selection_seq BIGINT NOT NULL DEFAULT 0,
|
||||||
|
selection_target UUID,
|
||||||
|
selection_expires_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS remote_commands (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
request_id UUID NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
result JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY(user_id, request_id)
|
||||||
|
);
|
||||||
@ -26,3 +26,5 @@ pyzbar==0.1.9
|
|||||||
pypdfium2==4.30.0
|
pypdfium2==4.30.0
|
||||||
reportlab==4.2.5
|
reportlab==4.2.5
|
||||||
openpyxl==3.1.5
|
openpyxl==3.1.5
|
||||||
|
|
||||||
|
qrcode==8.2
|
||||||
|
|||||||
35
static/css/case-create-v5.css
Normal file
35
static/css/case-create-v5.css
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/* Match the V5 workspace while retaining all creation fields and modes. */
|
||||||
|
.container.cc-page{max-width:1600px;padding:18px 20px 110px!important;background:var(--theme-surface-soft,#dbe3eb);border-radius:12px;--cc-blue:#336699;--cc-border:#bdcbd5}
|
||||||
|
.container.cc-page .cc-pagebar{margin-bottom:10px;font-size:12px}
|
||||||
|
.cc-v5-hero{background:linear-gradient(110deg,#162b40,#203d59);color:#f2f6fa;border:1px solid #496783;border-left:3px solid #6699cc;border-radius:12px;padding:16px 20px;margin-bottom:14px}
|
||||||
|
.cc-v5-eyebrow{font-size:10px;letter-spacing:1px;color:#c2d1df}
|
||||||
|
.container.cc-page .cc-v5-hero h1{font-size:25px;margin:3px 0 5px;font-weight:700;letter-spacing:-.5px}
|
||||||
|
.cc-v5-hero p{font-size:12px;color:#c2d1df;margin:0}
|
||||||
|
.container.cc-page .card-custom{border:1px solid var(--cc-border);border-radius:11px;box-shadow:none}
|
||||||
|
.container.cc-page .card-custom>.card-body{padding:18px 20px!important}
|
||||||
|
.container.cc-page .form-control,.container.cc-page .form-select{min-height:34px;font-size:13px;border-radius:7px;border-color:var(--cc-border)}
|
||||||
|
.container.cc-page .form-label{font-size:12px;margin-bottom:5px}
|
||||||
|
.container.cc-page textarea.form-control{background:var(--bg-element,#f6f8fa);padding:10px 12px;line-height:1.5}
|
||||||
|
.container.cc-page #cc-types{gap:6px}
|
||||||
|
.container.cc-page #cc-types>button{padding:7px 10px;min-height:34px;border-radius:7px;flex:0 1 auto}
|
||||||
|
.container.cc-page .cc-type-name{font-size:12px}
|
||||||
|
.container.cc-page #cc-types>button.active{border-color:var(--cc-blue);background:#eaf1f8;box-shadow:none}
|
||||||
|
.container.cc-page .cc-type-hint{font-size:10px;margin-top:2px}
|
||||||
|
.container.cc-page .cc-panel{border-color:var(--cc-border);border-radius:9px}
|
||||||
|
.container.cc-page .cc-panel>summary{padding:9px 12px}
|
||||||
|
.container.cc-page .cc-panel-icon{width:27px;height:27px;border-radius:6px;background:#eaf1f8;color:var(--cc-blue)}
|
||||||
|
.container.cc-page .cc-actions{padding:10px 12px;border-radius:9px;margin-top:14px!important;border-color:var(--cc-border);box-shadow:0 3px 12px #143b5614}
|
||||||
|
.container.cc-page #submitBtn{background:var(--cc-blue);border-color:var(--cc-blue);border-radius:7px;font-size:13px}
|
||||||
|
.cc-command-tools{margin:6px 0 8px;font-size:12px}
|
||||||
|
.cc-command-toolbar{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;color:var(--text-secondary,#667788)}
|
||||||
|
.cc-command-toolbar summary{cursor:pointer;font-size:12px;font-weight:600;color:var(--cc-blue)}
|
||||||
|
.cc-command-help{display:flex;flex-wrap:wrap;gap:4px;padding:8px 0}
|
||||||
|
.cc-command-help button{border:1px solid var(--cc-border);border-radius:6px;padding:4px 7px;background:var(--bg-surface,#fff);color:var(--cc-blue);font-size:11px}
|
||||||
|
.cc-command-preview{display:flex;flex-wrap:wrap;gap:5px;margin-top:5px}
|
||||||
|
.cc-command-chip{display:inline-flex;padding:4px 7px;border:1px solid #a9bfd5;border-radius:6px;background:#eaf1f8;color:#234d75;font-size:11px}
|
||||||
|
.cc-command-preview:empty{display:none}
|
||||||
|
.cc-command-recovery{padding:10px;border:1px solid #a9bfd5;border-radius:7px;margin-top:8px;background:#eaf1f8;color:#234d75}
|
||||||
|
.cc-command-recovery[hidden]{display:none}
|
||||||
|
[data-bs-theme=dark] .container.cc-page{--cc-border:#526675;--cc-blue:#91bce8;background:#101b24}
|
||||||
|
[data-bs-theme=dark] .container.cc-page #cc-types>button.active,[data-bs-theme=dark] .cc-command-chip,[data-bs-theme=dark] .cc-command-recovery{background:#203850;color:#dbeafa}
|
||||||
|
@media(max-width:600px){.container.cc-page{padding:12px 10px 110px!important}.container.cc-page .card-custom>.card-body{padding:14px 12px!important}.cc-v5-hero{padding:12px 14px}.container.cc-page .cc-v5-hero h1{font-size:22px}}
|
||||||
@ -283,3 +283,99 @@
|
|||||||
.case-v5 .v5-action-options button[aria-pressed=true],.case-v5 .v5-picker button[aria-pressed=true]{border-color:#7ca2c8}
|
.case-v5 .v5-action-options button[aria-pressed=true],.case-v5 .v5-picker button[aria-pressed=true]{border-color:#7ca2c8}
|
||||||
@media(max-width:1100px){.case-v5 .case-header-main{grid-template-columns:minmax(0,1fr) 250px;gap:10px 18px}.case-v5 #sag-titel-text{font-size:24px!important}.case-v5 .v5-hero-timeline>.card-body{grid-template-columns:repeat(4,minmax(0,1fr));gap:10px 12px}}
|
@media(max-width:1100px){.case-v5 .case-header-main{grid-template-columns:minmax(0,1fr) 250px;gap:10px 18px}.case-v5 #sag-titel-text{font-size:24px!important}.case-v5 .v5-hero-timeline>.card-body{grid-template-columns:repeat(4,minmax(0,1fr));gap:10px 12px}}
|
||||||
@media(max-width:767px){.case-v5 .header-bg{padding:12px 0!important}.case-v5 .case-header-main{grid-template-columns:minmax(0,1fr);gap:11px}.case-v5 .v5-hero-status{grid-column:1;grid-row:2;border-left:0;padding-left:0}.case-v5 .v5-hero-timeline{grid-column:1;grid-row:3}.case-v5 .v5-hero-timeline>.card-body{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.case-v5 .case-header-todo{grid-column:1;grid-row:4;width:100%}.case-v5 #sag-titel-text{font-size:23px!important}}
|
@media(max-width:767px){.case-v5 .header-bg{padding:12px 0!important}.case-v5 .case-header-main{grid-template-columns:minmax(0,1fr);gap:11px}.case-v5 .v5-hero-status{grid-column:1;grid-row:2;border-left:0;padding-left:0}.case-v5 .v5-hero-timeline{grid-column:1;grid-row:3}.case-v5 .v5-hero-timeline>.card-body{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.case-v5 .case-header-todo{grid-column:1;grid-row:4;width:100%}.case-v5 #sag-titel-text{font-size:23px!important}}
|
||||||
|
|
||||||
|
|
||||||
|
/* V5 facts belong to the hero; the case workspace has no left sidebar. */
|
||||||
|
.case-v5 .v5-board{display:grid;grid-template-columns:minmax(0,1fr) minmax(250px,300px);gap:18px}
|
||||||
|
.case-v5 .v5-board>main{grid-column:1;grid-row:1}
|
||||||
|
.case-v5 .v5-board>.v5-case-modules{grid-column:2;grid-row:1}
|
||||||
|
.case-v5 .header-bg{overflow:visible!important}
|
||||||
|
.case-v5 .header-bg:has(.v5-hero-facts-panel.show){position:relative;z-index:1040}
|
||||||
|
.case-v5 .v5-hero-facts-menu{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
|
||||||
|
.case-v5 .v5-hero-facts-button{display:inline-flex;align-items:center;gap:7px;border:1px solid #5c7a94;border-radius:7px;padding:6px 10px;background:#29465f;color:#edf5fc;font-size:12px;line-height:1.4}
|
||||||
|
.case-v5 .v5-hero-facts-button:hover,.case-v5 .v5-hero-facts-button[aria-expanded="true"]{background:#365c7e;border-color:#9abbd9}
|
||||||
|
.case-v5 .v5-hero-facts-button:focus-visible{outline:2px solid #b2d2f1;outline-offset:2px}
|
||||||
|
.case-v5 .v5-hero-facts-panel{width:340px;max-width:calc(100vw - 40px);padding:0;border:1px solid var(--case-border);border-radius:12px;box-shadow:0 14px 40px #142d4933;color:var(--text-color,#243449);background:var(--bg-surface,#fff);z-index:1050}
|
||||||
|
.case-v5 .v5-hero-facts-panel .card{margin:0;border:0!important;background:transparent!important}
|
||||||
|
.case-v5 .v5-hero-facts-panel .card-body{padding:18px!important}
|
||||||
|
.case-v5 .v5-hero-facts-panel .form-select{max-width:100%}
|
||||||
|
@media(min-width:768px) and (max-width:1250px){.case-v5 .v5-case-modules .right-modules-grid{display:flex!important;flex-direction:column!important}}
|
||||||
|
@media(max-width:767px){.case-v5 .v5-board{grid-template-columns:minmax(0,1fr);gap:14px}.case-v5 .v5-board>main{grid-column:1;grid-row:1}.case-v5 .v5-board>.v5-case-modules{grid-column:1;grid-row:2}.case-v5 .v5-hero-facts-menu{gap:6px;margin-top:12px}.case-v5 .v5-hero-facts-button{font-size:11px;padding:7px 8px}}
|
||||||
|
|
||||||
|
/* Use the wide hero as a compact work toolbar instead of stacked full-width rows. */
|
||||||
|
@media(min-width:1401px){
|
||||||
|
.case-v5 .case-header-main{grid-template-columns:minmax(320px,1fr) minmax(500px,620px) 250px;column-gap:22px;row-gap:12px;align-items:start}
|
||||||
|
.case-v5 .case-header-copy{grid-column:1;grid-row:1 / span 2;align-self:center}
|
||||||
|
.case-v5 .v5-hero-timeline{grid-column:2;grid-row:1;border-top:0!important;border-left:1px solid var(--hero-line)!important;padding:0 0 0 20px!important}
|
||||||
|
.case-v5 .v5-hero-timeline>.card-body{grid-template-columns:repeat(3,minmax(0,1fr)) minmax(110px,.8fr);gap:8px;padding:0!important}
|
||||||
|
.case-v5 .v5-hero-status{grid-column:3;grid-row:1;padding-left:16px;gap:8px}
|
||||||
|
.case-v5 .case-header-todo{grid-column:2 / -1;grid-row:2;justify-self:stretch;width:100%;margin:0!important}
|
||||||
|
.case-v5 .v5-hero-facts-menu{margin-top:11px}
|
||||||
|
}
|
||||||
|
@media(min-width:768px) and (max-width:1400px){
|
||||||
|
.case-v5 .case-header-main{grid-template-columns:minmax(0,1fr) 250px;column-gap:18px;row-gap:10px}
|
||||||
|
.case-v5 .v5-hero-timeline>.card-body{grid-template-columns:repeat(3,minmax(0,1fr)) minmax(100px,.7fr);gap:10px 14px}
|
||||||
|
.case-v5 .case-header-todo{width:100%;justify-self:stretch}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep command tools compact and preserve native hidden menu behaviour. */
|
||||||
|
.case-v5 .v5-action-menu[hidden]{display:none!important}
|
||||||
|
.case-v5 .case-command-panel{padding:4px 0;border:0;background:transparent;display:flex;flex-wrap:wrap;align-items:center;gap:4px 12px}
|
||||||
|
.case-v5 .case-command-hint{font-size:11px;margin:0;color:var(--text-muted,#667788)}
|
||||||
|
.case-v5 .case-command-help-disclosure{border:0;padding:0;margin:0;background:transparent;font-size:12px}
|
||||||
|
.case-v5 .case-command-help-disclosure>summary{padding:2px 0;font-size:12px}
|
||||||
|
.case-v5 .case-command-help-disclosure[open]{flex-basis:100%}
|
||||||
|
.case-v5 .case-command-suggestions:empty,.case-v5 .case-command-chips:empty,.case-v5 .case-command-status:empty{display:none}
|
||||||
|
.case-v5 .case-command-suggestions,.case-v5 .case-command-chips,.case-v5 .case-command-status{flex-basis:100%}
|
||||||
|
.case-v5 .case-command-help-item small{display:none}
|
||||||
|
.case-v5 .case-command-help-item{padding:5px 8px}
|
||||||
|
.case-v5 .case-command-panel [hidden]{display:none!important}
|
||||||
|
|
||||||
|
.case-v5 .right-modules-grid>[data-module="tags"]{order:-1;display:flex!important}
|
||||||
|
.case-v5 .case-command-panel .case-command-help-disclosure{margin-top:0}
|
||||||
|
.case-v5 .case-command-help-disclosure>summary{border:0;background:transparent}
|
||||||
|
|
||||||
|
/* Actions stay within reach above the editor, with one clear panel per task. */
|
||||||
|
.case-v5 .v5-composer-actions{margin:0 0 8px;align-items:center;gap:8px}
|
||||||
|
.case-v5 #v5ActionToggle{display:inline-flex;align-items:center;gap:7px;width:auto;height:32px;padding:5px 10px;font-size:12px;border-radius:7px}
|
||||||
|
.case-v5 .v5-action-menu{padding:10px;margin:0 0 8px;background:var(--case-primary-soft)}
|
||||||
|
.case-v5 .v5-action-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
|
||||||
|
.case-v5 .v5-action-options button{width:100%;padding:8px 10px;min-height:36px}
|
||||||
|
.case-v5 .v5-assignment-fields{display:grid;grid-template-columns:1fr 1fr;gap:6px 12px;padding:12px;margin:0 0 8px;border:1px solid var(--case-border);border-radius:9px;background:var(--bg-surface,#fff)}
|
||||||
|
.case-v5 .v5-assignment-fields[hidden]{display:none!important}
|
||||||
|
.case-v5 .v5-assignment-fields label[for=v5GroupSearch],.case-v5 #v5GroupSearch,.case-v5 #v5GroupOptions{grid-column:1}
|
||||||
|
.case-v5 .v5-assignment-fields label[for=v5UserSearch]{grid-column:2;grid-row:1}
|
||||||
|
.case-v5 #v5UserSearch{grid-column:2;grid-row:2}
|
||||||
|
.case-v5 #v5UserOptions{grid-column:2;grid-row:3}
|
||||||
|
.case-v5 .v5-assignment-fields label[for=v5AssignmentReason],.case-v5 #v5AssignmentReason,.case-v5 .v5-assignment-fields p{grid-column:1/-1}
|
||||||
|
.case-v5 .v5-picker{margin:0;max-height:110px;overflow:auto}
|
||||||
|
.case-v5 .v5-submit-row{display:flex;justify-content:flex-end;margin-top:8px}
|
||||||
|
@media(max-width:600px){.case-v5 .v5-action-options{grid-template-columns:repeat(2,minmax(0,1fr))}.case-v5 .v5-assignment-fields{display:flex;flex-direction:column}}
|
||||||
|
|
||||||
|
/* Employee-first assignment; groups stay behind an optional disclosure. */
|
||||||
|
.case-v5 .v5-assignment-fields{display:flex;flex-direction:column;gap:6px;padding:10px}
|
||||||
|
.case-v5 .v5-employee-buttons{display:flex;flex-wrap:wrap;gap:5px;max-height:145px}
|
||||||
|
.case-v5 .v5-employee-buttons button{display:inline-flex;align-items:center;justify-content:center;min-width:34px;height:30px;padding:3px 7px;font-size:11px;font-weight:650;border-radius:6px}
|
||||||
|
.case-v5 .v5-employee-buttons button[aria-pressed=true]{background:var(--case-primary);color:#fff;border-color:var(--case-primary);box-shadow:0 0 0 1px var(--case-primary)}
|
||||||
|
.case-v5 .v5-assignment-groups{font-size:11px;margin:2px 0 4px;color:var(--text-muted,#667788)}
|
||||||
|
.case-v5 .v5-assignment-groups summary{cursor:pointer}
|
||||||
|
.case-v5 .v5-assignment-groups[open] input{margin:6px 0}
|
||||||
|
|
||||||
|
/* Call history is a compact list in the narrow V5 sidebar. */
|
||||||
|
.case-v5 #caseCallHistoryBody{padding:0!important;max-height:320px;overflow-y:auto}
|
||||||
|
.case-v5 #caseCallHistoryBody .table-responsive{overflow:visible}
|
||||||
|
.case-v5 #caseCallHistoryBody table,.case-v5 #caseCallHistoryRows{display:block;width:100%}
|
||||||
|
.case-v5 #caseCallHistoryBody thead{display:none}
|
||||||
|
.case-v5 #caseCallHistoryRows tr{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 8px;padding:10px 12px;border-bottom:1px solid var(--case-border)}
|
||||||
|
.case-v5 #caseCallHistoryRows tr:last-child{border-bottom:0}
|
||||||
|
.case-v5 #caseCallHistoryRows td{padding:0!important;border:0!important;background:transparent!important;box-shadow:none!important;font-size:11px;line-height:1.5;min-width:0;color:var(--text-muted,#667788);overflow-wrap:anywhere}
|
||||||
|
.case-v5 #caseCallHistoryRows td:nth-child(3){grid-column:1/-1;grid-row:1;color:var(--text-color,#243449);font-size:13px;font-weight:600}
|
||||||
|
.case-v5 #caseCallHistoryRows td:nth-child(3)>div{display:flex!important;flex-wrap:nowrap!important;justify-content:space-between;gap:8px}
|
||||||
|
.case-v5 #caseCallHistoryRows td:nth-child(3) span{min-width:0;overflow-wrap:anywhere}
|
||||||
|
.case-v5 #caseCallHistoryRows td:nth-child(3) button{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:27px;height:27px;padding:0;border-radius:6px;border-color:var(--case-border);color:var(--case-primary)}
|
||||||
|
.case-v5 #caseCallHistoryRows td:nth-child(3) button:hover{background:var(--case-primary);border-color:var(--case-primary);color:#fff}
|
||||||
|
.case-v5 #caseCallHistoryRows td:nth-child(1){grid-column:1;grid-row:2;white-space:nowrap}
|
||||||
|
.case-v5 #caseCallHistoryRows td:nth-child(2){grid-column:1;grid-row:3;display:flex;align-items:center;gap:5px}
|
||||||
|
.case-v5 #caseCallHistoryRows td:nth-child(2) i{color:var(--case-primary);font-size:12px}
|
||||||
|
.case-v5 #caseCallHistoryRows td:nth-child(4){grid-column:1/-1;grid-row:4;font-size:10px}
|
||||||
|
.case-v5 #caseCallHistoryRows td:nth-child(5){grid-column:2;grid-row:3;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||||
|
|||||||
19
static/css/remote.css
Normal file
19
static/css/remote.css
Normal file
File diff suppressed because one or more lines are too long
@ -2189,11 +2189,13 @@
|
|||||||
select.innerHTML='<option value="invoice">Afregning vælges automatisk</option>';
|
select.innerHTML='<option value="invoice">Afregning vælges automatisk</option>';
|
||||||
select.hidden=true;select.disabled=true;panel.dataset.workflowActive='true';
|
select.hidden=true;select.disabled=true;panel.dataset.workflowActive='true';
|
||||||
byId('bbConvertRecommendation').textContent='Afregning vælges automatisk. Bogholderiet gennemgår og godkender tiden.';
|
byId('bbConvertRecommendation').textContent='Afregning vælges automatisk. Bogholderiet gennemgår og godkender tiden.';
|
||||||
byId('bbConvertSubmit').disabled=true;
|
byId('bbConvertSubmit').disabled=false;
|
||||||
|
byId('bbConvertSubmit').textContent='Gem tid';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
select.hidden=false;select.disabled=false;panel.dataset.workflowActive='false';
|
select.hidden=false;select.disabled=false;panel.dataset.workflowActive='false';
|
||||||
byId('bbConvertSubmit').disabled=false;
|
byId('bbConvertSubmit').disabled=false;
|
||||||
|
byId('bbConvertSubmit').textContent='Konverter tid';
|
||||||
const recommended = options.recommended || { method: 'invoice' };
|
const recommended = options.recommended || { method: 'invoice' };
|
||||||
let html = '<option value="invoice">Faktura</option>';
|
let html = '<option value="invoice">Faktura</option>';
|
||||||
(options.prepaid_cards || []).forEach(function (card) {
|
(options.prepaid_cards || []).forEach(function (card) {
|
||||||
@ -2218,7 +2220,7 @@
|
|||||||
const button = byId('bbConvertSubmit');
|
const button = byId('bbConvertSubmit');
|
||||||
const timeId = Number((panel && panel.dataset.timeId) || 0);
|
const timeId = Number((panel && panel.dataset.timeId) || 0);
|
||||||
if (!timeId || !button) return;
|
if (!timeId || !button) return;
|
||||||
if(panel.dataset.workflowActive==='true')return;
|
const workflowActive=panel.dataset.workflowActive==='true';
|
||||||
const selected = String(byId('bbConvertBillingMethod').value || 'invoice').split(':');
|
const selected = String(byId('bbConvertBillingMethod').value || 'invoice').split(':');
|
||||||
const payload = {
|
const payload = {
|
||||||
billing_method: selected[0],
|
billing_method: selected[0],
|
||||||
@ -2228,9 +2230,10 @@
|
|||||||
};
|
};
|
||||||
if (selected[0] === 'prepaid') payload.prepaid_card_id = Number(selected[1]);
|
if (selected[0] === 'prepaid') payload.prepaid_card_id = Number(selected[1]);
|
||||||
if (selected[0] === 'subscription') payload.fixed_price_agreement_id = Number(selected[1]);
|
if (selected[0] === 'subscription') payload.fixed_price_agreement_id = Number(selected[1]);
|
||||||
|
if(workflowActive){delete payload.billing_method;delete payload.prepaid_card_id;delete payload.fixed_price_agreement_id;}
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/timetracking/time/' + timeId + '/approve', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
const response = await fetch('/api/v1/timetracking/time/' + timeId + (workflowActive?'/register':'/approve'), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||||
const result = await response.json().catch(function () { return {}; });
|
const result = await response.json().catch(function () { return {}; });
|
||||||
if (!response.ok) throw new Error(result.detail || 'Kunne ikke konvertere tiden');
|
if (!response.ok) throw new Error(result.detail || 'Kunne ikke konvertere tiden');
|
||||||
panel.classList.add('d-none');
|
panel.classList.add('d-none');
|
||||||
|
|||||||
@ -20,7 +20,7 @@
|
|||||||
panel.setAttribute('aria-label', 'Kommandoer og forhåndsvisning');
|
panel.setAttribute('aria-label', 'Kommandoer og forhåndsvisning');
|
||||||
const hint = document.createElement('div');
|
const hint = document.createElement('div');
|
||||||
hint.className = 'case-command-hint';
|
hint.className = 'case-command-hint';
|
||||||
hint.textContent = 'Skriv / på en ny linje for handlinger. Gem udfører kommandoerne. Vælg filer uploader særskilt med det samme.';
|
hint.textContent = 'Skriv / for handlinger.';
|
||||||
const suggestions = document.createElement('div');
|
const suggestions = document.createElement('div');
|
||||||
suggestions.className = 'case-command-suggestions';
|
suggestions.className = 'case-command-suggestions';
|
||||||
const help = document.createElement('details');
|
const help = document.createElement('details');
|
||||||
@ -48,7 +48,7 @@
|
|||||||
gruppe:'Support "Begrundelse"', mail:'"Mailkladde"', note:'"Intern note"'
|
gruppe:'Support "Begrundelse"', mail:'"Mailkladde"', note:'"Intern note"'
|
||||||
};
|
};
|
||||||
help.className='case-command-help-disclosure';
|
help.className='case-command-help-disclosure';
|
||||||
helpTitle.textContent='Kommandoer · hjælp og eksempler';
|
helpTitle.textContent='/ Hjælp';
|
||||||
const intro=document.createElement('p');intro.className='case-command-help-intro';
|
const intro=document.createElement('p');intro.className='case-command-help-intro';
|
||||||
intro.textContent='Klik på en kommando for at indsætte et eksempel. Ret teksten og kontrollér handlingerne før gemning.';
|
intro.textContent='Klik på en kommando for at indsætte et eksempel. Ret teksten og kontrollér handlingerne før gemning.';
|
||||||
helpList.append(intro);
|
helpList.append(intro);
|
||||||
@ -189,12 +189,31 @@
|
|||||||
button.addEventListener('click',()=>{
|
button.addEventListener('click',()=>{
|
||||||
if(frozenText!==null)return;
|
if(frozenText!==null)return;
|
||||||
input.setRangeText(option.value,line.start+option.from,line.start+option.to,'end');
|
input.setRangeText(option.value,line.start+option.from,line.start+option.to,'end');
|
||||||
|
if(option.caret!==undefined) {
|
||||||
|
const caret=line.start+option.from+option.caret;
|
||||||
|
input.setSelectionRange(caret,caret);
|
||||||
|
}
|
||||||
input.focus();update();
|
input.focus();update();
|
||||||
});suggestions.append(button);
|
});suggestions.append(button);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function update() {
|
function update() {
|
||||||
generation++; clearTimeout(timer); serverValid = false;
|
generation++; clearTimeout(timer); serverValid = false;
|
||||||
|
// Insert the required text delimiters as soon as the preceding
|
||||||
|
// argument is complete, for both typing and menu selections.
|
||||||
|
if (frozenText === null && window.CaseCommandSuggestions) {
|
||||||
|
const line = currentLine();
|
||||||
|
const cursor = input.selectionStart - line.start;
|
||||||
|
if (cursor === line.text.length && /\s$/.test(line.text)) {
|
||||||
|
const textOption = window.CaseCommandSuggestions.suggest(line.text, cursor, completionValues)
|
||||||
|
.find(option => option.value === '"" ' && option.from === cursor);
|
||||||
|
if (textOption) {
|
||||||
|
input.setRangeText('""', input.selectionStart, input.selectionStart, 'end');
|
||||||
|
const caret = line.start + cursor + 1;
|
||||||
|
input.setSelectionRange(caret, caret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
parsed = window.CaseCommands.parse(input.value,{now:new Date()});
|
parsed = window.CaseCommands.parse(input.value,{now:new Date()});
|
||||||
chips.replaceChildren();
|
chips.replaceChildren();
|
||||||
parsed.commands.forEach(command => {
|
parsed.commands.forEach(command => {
|
||||||
@ -256,6 +275,7 @@
|
|||||||
if(!response.ok) throw new Error('Mailkladder kunne ikke hentes.');
|
if(!response.ok) throw new Error('Mailkladder kunne ikke hentes.');
|
||||||
const rows=await response.json(); draftList.replaceChildren();
|
const rows=await response.json(); draftList.replaceChildren();
|
||||||
draftTitle.textContent=`Mine gemte mailkladder (${rows.length})`;
|
draftTitle.textContent=`Mine gemte mailkladder (${rows.length})`;
|
||||||
|
drafts.hidden=!rows.length;
|
||||||
if(!rows.length) draftList.textContent='Ingen gemte mailkladder.';
|
if(!rows.length) draftList.textContent='Ingen gemte mailkladder.';
|
||||||
rows.forEach(draft=>{
|
rows.forEach(draft=>{
|
||||||
const button=document.createElement('button'); button.type='button';
|
const button=document.createElement('button'); button.type='button';
|
||||||
@ -318,13 +338,42 @@
|
|||||||
input.addEventListener('keyup',event=>{if(!['ArrowDown','ArrowUp','Enter','Escape'].includes(event.key)) renderSuggestions();});
|
input.addEventListener('keyup',event=>{if(!['ArrowDown','ArrowUp','Enter','Escape'].includes(event.key)) renderSuggestions();});
|
||||||
input.addEventListener('keydown',event=>{
|
input.addEventListener('keydown',event=>{
|
||||||
const options=[...suggestions.querySelectorAll('button')];
|
const options=[...suggestions.querySelectorAll('button')];
|
||||||
|
if (event.key === 'Enter' && suggestionIndex < 0 && !event.shiftKey
|
||||||
|
&& !event.ctrlKey && !event.metaKey && !event.altKey && !event.isComposing
|
||||||
|
&& frozenText === null && input.selectionStart === input.selectionEnd) {
|
||||||
|
const line = currentLine();
|
||||||
|
const cursor = input.selectionStart - line.start;
|
||||||
|
if (/^\s*\/\/?\S+/.test(line.text)) {
|
||||||
|
let opening = -1;
|
||||||
|
for (let index = 0; index < line.text.length; index++) {
|
||||||
|
if (line.text[index] === '\\' && opening >= 0
|
||||||
|
&& ['"', '\\'].includes(line.text[index + 1])) { index++; continue; }
|
||||||
|
if (line.text[index] !== '"') continue;
|
||||||
|
if (opening < 0) opening = index;
|
||||||
|
else {
|
||||||
|
if (cursor > opening && cursor <= index) {
|
||||||
|
event.preventDefault();
|
||||||
|
const end = line.start + line.text.length;
|
||||||
|
input.setRangeText('\n', end, end, 'end');
|
||||||
|
update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
opening = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if(!options.length) return;
|
if(!options.length) return;
|
||||||
if(event.key==='Escape') {suggestions.replaceChildren();suggestionIndex=-1;return;}
|
if(event.key==='Escape') {suggestions.replaceChildren();suggestionIndex=-1;return;}
|
||||||
if(['ArrowDown','ArrowUp'].includes(event.key)) {
|
if(['ArrowDown','ArrowUp'].includes(event.key)) {
|
||||||
event.preventDefault(); suggestionIndex=(suggestionIndex+(event.key==='ArrowDown'?1:-1)+options.length)%options.length;
|
event.preventDefault(); suggestionIndex=(suggestionIndex+(event.key==='ArrowDown'?1:-1)+options.length)%options.length;
|
||||||
options.forEach((option,index)=>{option.classList.toggle('is-active',index===suggestionIndex);option.setAttribute('aria-pressed',String(index===suggestionIndex));});
|
options.forEach((option,index)=>{option.classList.toggle('is-active',index===suggestionIndex);option.setAttribute('aria-pressed',String(index===suggestionIndex));});
|
||||||
options[suggestionIndex].scrollIntoView({block:'nearest'});
|
options[suggestionIndex].scrollIntoView({block:'nearest'});
|
||||||
} else if(event.key==='Enter' && suggestionIndex>=0) {event.preventDefault();options[suggestionIndex].click();}
|
} else if(event.key==='Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey
|
||||||
|
&& !event.altKey && !event.isComposing && frozenText===null
|
||||||
|
&& input.selectionStart===input.selectionEnd) {
|
||||||
|
event.preventDefault();options[suggestionIndex>=0?suggestionIndex:0].click();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
update();
|
update();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -2,12 +2,21 @@
|
|||||||
(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;if(root)root.CaseCommandSuggestions=api;})(typeof window!=='undefined'?window:globalThis,function(){
|
(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;if(root)root.CaseCommandSuggestions=api;})(typeof window!=='undefined'?window:globalThis,function(){
|
||||||
'use strict';
|
'use strict';
|
||||||
const names=['tid','tildel','forventet','undersag','mig','todo','afventer','løsning','deadline','udsat','fil','hjælp','timer','relation','prioritet','gruppe','mail','note'];
|
const names=['tid','tildel','forventet','undersag','mig','todo','afventer','løsning','deadline','udsat','fil','hjælp','timer','relation','prioritet','gruppe','mail','note'];
|
||||||
|
const textCommands=new Set(['undersag','todo','note','mail','løsning']);
|
||||||
const descriptions={tid:'Registrer tid',tildel:'Vælg medarbejder',forventet:'Forventet tid',undersag:'Ny undersag',mig:'Tildel mig',todo:'Ny opgave',afventer:'Afventer kunde',løsning:'Ny løsning',deadline:'Deadline',udsat:'Udsæt sagen',fil:'Vedhæft filer',hjælp:'Vis hjælp',timer:'Start eller pause',relation:'Kobl sag',prioritet:'Prioritet',gruppe:'Vælg gruppe',mail:'Mailkladde',note:'Intern note'};
|
const descriptions={tid:'Registrer tid',tildel:'Vælg medarbejder',forventet:'Forventet tid',undersag:'Ny undersag',mig:'Tildel mig',todo:'Ny opgave',afventer:'Afventer kunde',løsning:'Ny løsning',deadline:'Deadline',udsat:'Udsæt sagen',fil:'Vedhæft filer',hjælp:'Vis hjælp',timer:'Start eller pause',relation:'Kobl sag',prioritet:'Prioritet',gruppe:'Vælg gruppe',mail:'Mailkladde',note:'Intern note'};
|
||||||
const quote=value=>'"'+String(value).replace(/\\/g,'\\\\').replace(/"/g,'\\"')+'"';
|
const quote=value=>'"'+String(value).replace(/\\/g,'\\\\').replace(/"/g,'\\"')+'"';
|
||||||
|
const initials=user=>String(user.full_name||'').trim().split(/\s+/).filter(Boolean).map(part=>part[0]).join('').toLocaleLowerCase('da');
|
||||||
|
function employeeToken(user,users){
|
||||||
|
const short=initials(user);
|
||||||
|
const matches=token=>users.filter(candidate=>[String(candidate.user_id),String(candidate.username||'').toLocaleLowerCase('da'),String(candidate.full_name||'').trim().toLocaleLowerCase('da'),initials(candidate)].includes(token.toLocaleLowerCase('da'))).length;
|
||||||
|
if(short && matches(short)===1)return short;
|
||||||
|
if(user.username && matches(user.username)===1)return user.username;
|
||||||
|
return quote(user.full_name||user.username||'');
|
||||||
|
}
|
||||||
function suggest(line,cursor,values={}){
|
function suggest(line,cursor,values={}){
|
||||||
const before=line.slice(0,cursor),match=/^(\s*)(\/\/|\/)([^\s]*)/.exec(before);if(!match)return [];
|
const before=line.slice(0,cursor),match=/^(\s*)(\/\/|\/)([^\s]*)/.exec(before);if(!match)return [];
|
||||||
const start=match[1].length,prefix=match[2],name=match[3].toLowerCase(),commandEnd=start+prefix.length+(/^(?:\s*)(?:\/\/|\/)([^\s]*)/.exec(line)?.[1] || name).length;
|
const start=match[1].length,prefix=match[2],name=match[3].toLowerCase(),commandEnd=start+prefix.length+(/^(?:\s*)(?:\/\/|\/)([^\s]*)/.exec(line)?.[1] || name).length;
|
||||||
if(cursor<=commandEnd)return names.filter(n=>n.startsWith(name)).map(n=>({label:`${prefix}${n}`,hint:descriptions[n],value:`${prefix}${n} `,from:start,to:commandEnd}));
|
if(cursor<=commandEnd)return names.filter(n=>n.startsWith(name)).map(n=>({label:`${prefix}${n}`,hint:descriptions[n],value:`${prefix}${n} ${textCommands.has(n)?'""':''}`,caret:textCommands.has(n)?prefix.length+n.length+2:undefined,from:start,to:commandEnd}));
|
||||||
const argsStart=commandEnd+1;
|
const argsStart=commandEnd+1;
|
||||||
const matches=[...line.slice(argsStart).matchAll(/"(?:\\.|[^"\\])*(?:"|$)|\S+/g)].map(m=>({text:m[0],start:argsStart+m.index,end:argsStart+m.index+m[0].length}));
|
const matches=[...line.slice(argsStart).matchAll(/"(?:\\.|[^"\\])*(?:"|$)|\S+/g)].map(m=>({text:m[0],start:argsStart+m.index,end:argsStart+m.index+m[0].length}));
|
||||||
let index=matches.findIndex(t=>cursor>=t.start&&cursor<=t.end);if(index<0)index=matches.filter(t=>t.end<cursor).length;
|
let index=matches.findIndex(t=>cursor>=t.start&&cursor<=t.end);if(index<0)index=matches.filter(t=>t.end<cursor).length;
|
||||||
@ -17,7 +26,7 @@
|
|||||||
const durations=['15m','30m','45m','1h','1,5h','2h'];
|
const durations=['15m','30m','45m','1h','1,5h','2h'];
|
||||||
if((name==='tid'||name==='forventet')&&index===0)options=durations.map(v=>[v,v,'Minutter eller timer']);
|
if((name==='tid'||name==='forventet')&&index===0)options=durations.map(v=>[v,v,'Minutter eller timer']);
|
||||||
if(name==='tid'&&index===1)options=['research','support','development','troubleshooting','on_site','meeting','other'].map(v=>[v,v,'Arbejdets kategori']);
|
if(name==='tid'&&index===1)options=['research','support','development','troubleshooting','on_site','meeting','other'].map(v=>[v,v,'Arbejdets kategori']);
|
||||||
if((name==='tildel'&&index===0)||(name==='todo'&&index===1&&(query.startsWith('@')||token?.text.startsWith('@')||!query)))options=(values.users||[]).map(u=>[u.full_name||u.username,`${name==='todo'?'@':''}${u.user_id}`,u.username||'Medarbejder']);
|
if((name==='tildel'&&index===0)||(name==='todo'&&index===1&&(query.startsWith('@')||token?.text.startsWith('@')||!query)))options=(values.users||[]).map(u=>{const token=employeeToken(u,values.users||[]);return [u.full_name||u.username,name==='todo'?(token.startsWith('"')?quote('@'+(u.full_name||u.username)):'@'+token):token,`${initials(u)} · ${u.username||'Medarbejder'}`];});
|
||||||
if(name==='gruppe'&&index===0)options=(values.groups||[]).map(g=>[g.name,quote(g.name),'Gruppe']);
|
if(name==='gruppe'&&index===0)options=(values.groups||[]).map(g=>[g.name,quote(g.name),'Gruppe']);
|
||||||
if(name==='prioritet'&&index===0)options=['lav','normal','høj','kritisk'].map(v=>[v,v,'Prioritet']);
|
if(name==='prioritet'&&index===0)options=['lav','normal','høj','kritisk'].map(v=>[v,v,'Prioritet']);
|
||||||
if(name==='afventer'&&index===0)options=[['kunde','kunde','Afvent kunden og opret todo']];
|
if(name==='afventer'&&index===0)options=[['kunde','kunde','Afvent kunden og opret todo']];
|
||||||
@ -29,8 +38,8 @@
|
|||||||
if(name==='todo'&&((index===1&&!token?.text.startsWith('@'))||index===2))options.push(...['i morgen','fredag','mandag'].map(v=>[v,v,'Opgavens frist']));
|
if(name==='todo'&&((index===1&&!token?.text.startsWith('@'))||index===2))options.push(...['i morgen','fredag','mandag'].map(v=>[v,v,'Opgavens frist']));
|
||||||
const textFields={undersag:[0,'Titel'],todo:[0,'Opgave'],note:[0,'Intern note'],mail:[0,'Mailtekst'],løsning:[0,'Løsning'],tildel:[1,'Begrundelse'],gruppe:[1,'Begrundelse'],prioritet:[1,'Begrundelse'],afventer:[1,'Begrundelse'],tid:[2,'Beskrivelse']};
|
const textFields={undersag:[0,'Titel'],todo:[0,'Opgave'],note:[0,'Intern note'],mail:[0,'Mailtekst'],løsning:[0,'Løsning'],tildel:[1,'Begrundelse'],gruppe:[1,'Begrundelse'],prioritet:[1,'Begrundelse'],afventer:[1,'Begrundelse'],tid:[2,'Beskrivelse']};
|
||||||
const field=textFields[name] || (name==='timer' && matches[0]?.text==='start' ? [1,'Beskrivelse'] : null);
|
const field=textFields[name] || (name==='timer' && matches[0]?.text==='start' ? [1,'Beskrivelse'] : null);
|
||||||
if(field && index===field[0] && !query)options.push([`Skriv ${field[1].toLowerCase()}`,quote(field[1]),'Tekst i dobbelte anførselstegn']);
|
if(field && index===field[0] && !query)options.push([`Skriv ${field[1].toLowerCase()}`,quote(''),'Tekst i dobbelte anførselstegn']);
|
||||||
return options.filter(([label,value,hint])=>`${label} ${value} ${hint}`.toLocaleLowerCase('da').includes(query)).slice(0,30).map(([label,value,hint])=>({label,hint,value:value+' ',from,to}));
|
return options.filter(([label,value,hint])=>`${label} ${value} ${hint}`.toLocaleLowerCase('da').includes(query)).slice(0,30).map(([label,value,hint])=>({label,hint,value:value+' ',caret:value==='""'?1:undefined,from,to}));
|
||||||
}
|
}
|
||||||
return {suggest};
|
return {suggest};
|
||||||
});
|
});
|
||||||
|
|||||||
168
static/js/case-create-commands.js
Normal file
168
static/js/case-create-commands.js
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
/* Creation uses the existing command parser and case command endpoints. */
|
||||||
|
(function(root,factory){
|
||||||
|
const api=factory();
|
||||||
|
if(typeof module==='object'&&module.exports)module.exports=api;
|
||||||
|
if(root && root.document) api.init(root);
|
||||||
|
})(typeof window!=='undefined'?window:null,function(){
|
||||||
|
'use strict';
|
||||||
|
class CommandRun {
|
||||||
|
constructor(request,uuid){this.request=request;this.uuid=uuid;this.pending=null;}
|
||||||
|
attach(caseId,commands){
|
||||||
|
if(!commands.length)return;
|
||||||
|
this.pending={caseId,body:{request_id:this.uuid(),comment:'',internal:true,billing_mode:'automatic',commands}};
|
||||||
|
}
|
||||||
|
async execute(){
|
||||||
|
if(!this.pending)return;
|
||||||
|
const {caseId,body}=this.pending;
|
||||||
|
await this.request(`/api/v1/sag/${caseId}/command-execute`,body);
|
||||||
|
this.pending=null;
|
||||||
|
return caseId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function init(root){
|
||||||
|
const doc=root.document, input=doc.getElementById('beskrivelse');
|
||||||
|
if(!input || !root.CaseCommands || !root.CaseCommandSuggestions)return;
|
||||||
|
const config=JSON.parse(doc.getElementById('caseCreateCommandData').textContent);
|
||||||
|
const values={users:(config.users||[]).map(user=>({...user,full_name:user.full_name,username:user.username||user.display_name})),groups:config.groups||[]};
|
||||||
|
const panel=doc.createElement('section');panel.className='cc-command-tools';
|
||||||
|
const toolbar=doc.createElement('div');toolbar.className='cc-command-toolbar';
|
||||||
|
const hint=doc.createElement('small');hint.textContent='Skriv / på en ny linje for handlinger.';
|
||||||
|
const help=doc.createElement('details'),summary=doc.createElement('summary');summary.textContent='/ Hjælp';
|
||||||
|
const helpList=doc.createElement('div');helpList.className='cc-command-help';help.append(summary,helpList);
|
||||||
|
const suggestions=doc.createElement('div');suggestions.className='case-command-suggestions';
|
||||||
|
const preview=doc.createElement('div');preview.className='cc-command-preview';preview.setAttribute('aria-live','polite');
|
||||||
|
const recovery=doc.createElement('div');recovery.className='cc-command-recovery';recovery.hidden=true;
|
||||||
|
toolbar.append(hint,help);panel.append(toolbar,suggestions,preview,recovery);input.after(panel);
|
||||||
|
let active=-1, options=[],storageKey=null,prepared=null;
|
||||||
|
const request=async(url,body)=>{
|
||||||
|
const response=await root.fetch(url,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||||
|
const data=await response.json();
|
||||||
|
if(!response.ok){
|
||||||
|
const error=new Error(typeof data.detail==='string'?data.detail:'Kommandoerne kunne ikke udføres.');
|
||||||
|
error.definite=[400,403,404,409,422].includes(response.status);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
const run=new CommandRun(request,()=>root.crypto.randomUUID());
|
||||||
|
const ready=(async()=>{
|
||||||
|
try {
|
||||||
|
const response=await root.fetch('/api/v1/auth/me',{credentials:'include'});
|
||||||
|
if(!response.ok)return;
|
||||||
|
const actor=await response.json();
|
||||||
|
if(!actor.id && !actor.user_id)return;
|
||||||
|
storageKey=`bmc:create-command-run:${actor.id||actor.user_id}`;
|
||||||
|
const stored=root.sessionStorage.getItem(storageKey);
|
||||||
|
if(stored){run.pending=JSON.parse(stored);if(run.pending.draftText)input.value=run.pending.draftText;}
|
||||||
|
} catch(_) { /* The current page still supports an in-memory retry. */ }
|
||||||
|
showRecovery();
|
||||||
|
})();
|
||||||
|
function persist(){try{if(storageKey){if(run.pending)root.sessionStorage.setItem(storageKey,JSON.stringify(run.pending));else root.sessionStorage.removeItem(storageKey);}}catch(_){}}
|
||||||
|
function showRecovery(){
|
||||||
|
recovery.replaceChildren();recovery.hidden=!run.pending;
|
||||||
|
if(!run.pending)return;
|
||||||
|
input.readOnly=!run.pending.canEdit;
|
||||||
|
const link=doc.createElement('a');link.href=`/sag/${run.pending.caseId}/v5`;link.textContent=`Åbn den oprettede sag #${run.pending.caseId}`;
|
||||||
|
recovery.append(doc.createTextNode('Sagen er oprettet. Kommandoerne afventer. '),link);
|
||||||
|
doc.getElementById('submitBtn').textContent=run.pending.canEdit?'Gem rettede kommandoer':'Prøv kommandoerne igen';
|
||||||
|
}
|
||||||
|
function line(){const start=input.value.lastIndexOf('\n',input.selectionStart-1)+1;const end=input.value.indexOf('\n',input.selectionStart);return {start,text:input.value.slice(start,end<0?input.value.length:end)};}
|
||||||
|
function choose(option){
|
||||||
|
if(input.readOnly)return;
|
||||||
|
const current=line();
|
||||||
|
input.setRangeText(option.value,current.start+option.from,current.start+option.to,'end');
|
||||||
|
if(option.caret!==undefined){const caret=current.start+option.from+option.caret;input.setSelectionRange(caret,caret);}
|
||||||
|
input.focus();update();input.dispatchEvent(new root.Event('input',{bubbles:true}));
|
||||||
|
}
|
||||||
|
function renderSuggestions(){
|
||||||
|
suggestions.replaceChildren();active=-1;
|
||||||
|
const current=line();options=root.CaseCommandSuggestions.suggest(current.text,input.selectionStart-current.start,values);
|
||||||
|
options.forEach(option=>{
|
||||||
|
const button=doc.createElement('button');button.type='button';
|
||||||
|
const title=doc.createElement('strong');title.textContent=option.label;
|
||||||
|
const detail=doc.createElement('small');detail.textContent=option.hint;
|
||||||
|
button.append(title,detail);button.addEventListener('click',()=>choose(option));suggestions.append(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function update(){
|
||||||
|
if(run.pending){showRecovery();if(!run.pending.canEdit)return;}
|
||||||
|
const current=line(),cursor=input.selectionStart-current.start;
|
||||||
|
if(cursor===current.text.length && /\s$/.test(current.text)){
|
||||||
|
const option=root.CaseCommandSuggestions.suggest(current.text,cursor,values).find(row=>row.value==='"" '&&row.from===cursor);
|
||||||
|
if(option){input.setRangeText('""',input.selectionStart,input.selectionStart,'end');const caret=current.start+cursor+1;input.setSelectionRange(caret,caret);}
|
||||||
|
}
|
||||||
|
const parsed=root.CaseCommands.parse(input.value);
|
||||||
|
if(parsed.commands.some(command=>command.type==='help'))help.open=true;
|
||||||
|
preview.replaceChildren();
|
||||||
|
parsed.commands.forEach(command=>{
|
||||||
|
const chip=doc.createElement('span');chip.className='cc-command-chip';
|
||||||
|
const target=command.scope==='main'?'Ny sag':`Undersag ${command.scope+1}`;
|
||||||
|
const name=root.CaseCommands.names.find(name=>({tid:'time',tildel:'assign',forventet:'estimate',undersag:'subcase',mig:'self',afventer:'waiting',løsning:'solution',udsat:'defer',fil:'file',hjælp:'help',prioritet:'priority',gruppe:'group'}[name]||name)===command.type)||command.type;
|
||||||
|
chip.textContent=`/${name} · ${command.title||command.assignee||command.minutes&&command.minutes+' min'||command.date||command.action||target} · ${target}`;
|
||||||
|
preview.append(chip);
|
||||||
|
});
|
||||||
|
if(parsed.errors.length){const error=doc.createElement('small');error.className='text-danger';error.textContent=parsed.errors.map(error=>`Linje ${error.line}: ${error.message}`).join(' · ');preview.append(error);}
|
||||||
|
renderSuggestions();
|
||||||
|
}
|
||||||
|
root.CaseCommands.names.forEach(name=>{
|
||||||
|
const button=doc.createElement('button');button.type='button';button.textContent='/'+name;
|
||||||
|
button.addEventListener('click',()=>{
|
||||||
|
if(input.readOnly)return;
|
||||||
|
const prefix=input.value&&!input.value.endsWith('\n')?'\n':'';
|
||||||
|
input.value+=prefix+'/'+name+' ';input.setSelectionRange(input.value.length,input.value.length);input.focus();update();input.dispatchEvent(new root.Event('input',{bubbles:true}));
|
||||||
|
});helpList.append(button);
|
||||||
|
});
|
||||||
|
input.addEventListener('input',update);input.addEventListener('click',renderSuggestions);
|
||||||
|
input.addEventListener('keydown',event=>{
|
||||||
|
if(event.isComposing||event.ctrlKey||event.metaKey||event.altKey||event.shiftKey)return;
|
||||||
|
if(event.key==='Escape'){suggestions.replaceChildren();options=[];active=-1;return;}
|
||||||
|
if(event.key==='ArrowDown'||event.key==='ArrowUp'){
|
||||||
|
if(!options.length)return;event.preventDefault();active=(active+(event.key==='ArrowDown'?1:-1)+options.length)%options.length;
|
||||||
|
[...suggestions.children].forEach((button,index)=>button.classList.toggle('is-active',index===active));return;
|
||||||
|
}
|
||||||
|
if(event.key!=='Enter'||input.selectionStart!==input.selectionEnd)return;
|
||||||
|
const current=line(),cursor=input.selectionStart-current.start;
|
||||||
|
if(active<0&&/^\s*\/\/?\S+/.test(current.text)){
|
||||||
|
let opening=-1;
|
||||||
|
for(let index=0;index<current.text.length;index++){
|
||||||
|
if(current.text[index]==='\\'&&opening>=0&&['"','\\'].includes(current.text[index+1])){index++;continue;}
|
||||||
|
if(current.text[index]!=='"')continue;
|
||||||
|
if(opening<0)opening=index;
|
||||||
|
else {if(cursor>opening&&cursor<=index){event.preventDefault();const end=current.start+current.text.length;input.setRangeText('\n',end,end,'end');update();input.dispatchEvent(new root.Event('input',{bubbles:true}));return;}opening=-1;}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(options.length){event.preventDefault();choose(options[active>=0?active:0]);}
|
||||||
|
});
|
||||||
|
root.caseCreateCommands={
|
||||||
|
ready,
|
||||||
|
prepare(){
|
||||||
|
const parsed=root.CaseCommands.parse(input.value);
|
||||||
|
if(parsed.errors.length)throw new Error(parsed.errors.map(error=>`Linje ${error.line}: ${error.message}`).join(' · '));
|
||||||
|
if(!parsed.comment)throw new Error('Skriv også en beskrivelse af sagen over kommandoerne.');
|
||||||
|
if(parsed.commands.some(command=>command.type==='file'))throw new Error('Vedhæft filer på sagen efter oprettelse. Fjern /fil før du opretter.');
|
||||||
|
prepared=parsed;return parsed;
|
||||||
|
},
|
||||||
|
hasPending:()=>Boolean(run.pending),
|
||||||
|
async created(result){run.attach(result.id,prepared?.commands||[]);if(run.pending)run.pending.draftText=input.value;persist();showRecovery();await this.retry();},
|
||||||
|
async retry(){
|
||||||
|
if(!run.pending)return;
|
||||||
|
if(run.pending.canEdit){
|
||||||
|
const caseId=run.pending.caseId;
|
||||||
|
const parsed=this.prepare();
|
||||||
|
run.attach(caseId,parsed.commands);
|
||||||
|
// An empty command list explicitly finishes the remaining step.
|
||||||
|
if(!parsed.commands.length){run.pending=null;persist();return;}
|
||||||
|
run.pending.draftText=input.value;persist();
|
||||||
|
}
|
||||||
|
input.readOnly=true;
|
||||||
|
try {await run.execute();persist();showRecovery();}
|
||||||
|
catch(error){run.pending.canEdit=Boolean(error.definite);persist();showRecovery();throw new Error(`Sag #${run.pending.caseId} er oprettet, men kommandoerne afventer: ${error.message}`);}
|
||||||
|
finally {input.readOnly=Boolean(run.pending&&!run.pending.canEdit);}
|
||||||
|
},
|
||||||
|
setCreating:busy=>{input.readOnly=busy||Boolean(run.pending&&!run.pending.canEdit);},
|
||||||
|
pendingCaseId:()=>run.pending?.caseId
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
return {CommandRun,init};
|
||||||
|
});
|
||||||
@ -90,31 +90,48 @@
|
|||||||
syncDateLimits();
|
syncDateLimits();
|
||||||
const board = document.createElement('div');
|
const board = document.createElement('div');
|
||||||
board.className = 'v5-board';
|
board.className = 'v5-board';
|
||||||
const facts = document.createElement('aside');
|
|
||||||
facts.className = 'v5-case-facts';
|
|
||||||
facts.setAttribute('aria-label', 'Kunde og sagsoplysninger');
|
|
||||||
const center = document.createElement('main');
|
const center = document.createElement('main');
|
||||||
const modules = document.createElement('aside');
|
const modules = document.createElement('aside');
|
||||||
modules.className = 'v5-case-modules';
|
modules.className = 'v5-case-modules';
|
||||||
modules.setAttribute('aria-label', 'Todo, filer og moduler');
|
modules.setAttribute('aria-label', 'Todo, filer og moduler');
|
||||||
const tabs = byId('caseTabs');
|
const tabs = byId('caseTabs');
|
||||||
tabs.before(board);
|
tabs.before(board);
|
||||||
board.append(facts, center, modules);
|
board.append(center, modules);
|
||||||
const widgets = [...shell.querySelectorAll('.case-v4-widget-dashboard .row > div > .card')];
|
const widgets = [...shell.querySelectorAll('.case-v4-widget-dashboard .row > div > .card')];
|
||||||
[3, 1, 0].forEach(index => { if (widgets[index]) facts.append(widgets[index]); });
|
// Move the original controls into hero menus: handlers, IDs and values survive.
|
||||||
const factsDisclosure = document.createElement('details');
|
const factsMenu = document.createElement('nav');
|
||||||
factsDisclosure.className = 'v5-facts-disclosure';
|
factsMenu.className = 'v5-hero-facts-menu';
|
||||||
const factsSummary = document.createElement('summary');
|
factsMenu.setAttribute('aria-label', 'Kontakt, tildeling og klassifikation');
|
||||||
factsSummary.textContent = 'Kunde, kontakt og sagsoplysninger';
|
[[3, 'Kontakt', 'person-vcard'], [1, 'Tildeling', 'people'], [0, 'Klassifikation', 'tags']].forEach(([index, label, icon]) => {
|
||||||
const factsContent = document.createElement('div');
|
const card = widgets[index];
|
||||||
factsContent.className = 'v5-facts-content';
|
if (!card) return;
|
||||||
factsContent.append(...facts.children);
|
const dropdown = document.createElement('div');
|
||||||
factsDisclosure.append(factsSummary, factsContent);
|
dropdown.className = 'dropdown';
|
||||||
facts.append(factsDisclosure);
|
const button = document.createElement('button');
|
||||||
const compactScreen = window.matchMedia('(max-width: 1400px)');
|
button.type = 'button';
|
||||||
function adaptFacts(event) { factsDisclosure.open = !event.matches; }
|
button.id = `v5HeroFacts${index}`;
|
||||||
adaptFacts(compactScreen);
|
button.className = 'v5-hero-facts-button dropdown-toggle';
|
||||||
compactScreen.addEventListener('change', adaptFacts);
|
button.dataset.bsToggle = 'dropdown';
|
||||||
|
button.dataset.bsAutoClose = 'outside';
|
||||||
|
button.setAttribute('aria-expanded', 'false');
|
||||||
|
button.setAttribute('aria-controls', `v5HeroFactsPanel${index}`);
|
||||||
|
button.innerHTML = `<i class="bi bi-${icon}" aria-hidden="true"></i><span>${label}</span>`;
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.id = `v5HeroFactsPanel${index}`;
|
||||||
|
panel.className = 'dropdown-menu v5-hero-facts-panel v5-case-facts';
|
||||||
|
panel.setAttribute('aria-labelledby', button.id);
|
||||||
|
panel.append(card);
|
||||||
|
dropdown.append(button, panel);
|
||||||
|
factsMenu.append(dropdown);
|
||||||
|
// Open secondary dialogs without leaving this popup above their backdrop.
|
||||||
|
panel.addEventListener('click', event => {
|
||||||
|
const target = event.target.closest('button, a');
|
||||||
|
if (target && !target.matches('[data-bs-toggle="dropdown"]')) {
|
||||||
|
bootstrap.Dropdown.getOrCreateInstance(button).hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
shell.querySelector('.case-header-copy').append(factsMenu);
|
||||||
const anyDeskButton = document.createElement('button');
|
const anyDeskButton = document.createElement('button');
|
||||||
anyDeskButton.id = 'v5AnyDeskOpenButton';
|
anyDeskButton.id = 'v5AnyDeskOpenButton';
|
||||||
anyDeskButton.type = 'button';
|
anyDeskButton.type = 'button';
|
||||||
@ -170,7 +187,30 @@
|
|||||||
details.prepend(detailsRow);
|
details.prepend(detailsRow);
|
||||||
detailsRow.append(left);
|
detailsRow.append(left);
|
||||||
modules.append(right);
|
modules.append(right);
|
||||||
|
// Keep the existing call action and data; give the narrow sidebar a list layout.
|
||||||
|
byId('caseCallHistoryRows')?.querySelectorAll('tr').forEach(row => {
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
const callButton = cells[2]?.querySelector('button');
|
||||||
|
if (callButton) {
|
||||||
|
callButton.title = `Ring til ${cells[2].querySelector('span')?.textContent.trim() || 'nummeret'}`;
|
||||||
|
callButton.setAttribute('aria-label', callButton.title);
|
||||||
|
callButton.innerHTML = '<i class="bi bi-telephone-outbound" aria-hidden="true"></i>';
|
||||||
|
}
|
||||||
|
if (cells[1]) {
|
||||||
|
const outgoing = cells[1].textContent.trim() === 'Udgående';
|
||||||
|
const icon = document.createElement('i');
|
||||||
|
icon.className = `bi bi-arrow-${outgoing ? 'up-right' : 'down-left'}`;
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
cells[1].prepend(icon);
|
||||||
|
}
|
||||||
|
});
|
||||||
const rightGrid = right.querySelector('.right-modules-grid');
|
const rightGrid = right.querySelector('.right-modules-grid');
|
||||||
|
const tagsCard = rightGrid?.querySelector('[data-module="tags"]');
|
||||||
|
if (tagsCard) {
|
||||||
|
rightGrid.prepend(tagsCard);
|
||||||
|
tagsCard.classList.remove('d-none');
|
||||||
|
tagsCard.style.setProperty('display', 'flex', 'important');
|
||||||
|
}
|
||||||
['todo-steps', 'files', 'hardware', 'time'].reverse().forEach(key => {
|
['todo-steps', 'files', 'hardware', 'time'].reverse().forEach(key => {
|
||||||
const card = shell.querySelector(`.right-module-card[data-module="${key}"]`);
|
const card = shell.querySelector(`.right-module-card[data-module="${key}"]`);
|
||||||
if (card) rightGrid.prepend(card);
|
if (card) rightGrid.prepend(card);
|
||||||
@ -203,6 +243,39 @@
|
|||||||
const legacyChips = form.querySelector('.comment-action-chips');
|
const legacyChips = form.querySelector('.comment-action-chips');
|
||||||
legacyChips.before(byId('caseV5ComposerTools').content.cloneNode(true));
|
legacyChips.before(byId('caseV5ComposerTools').content.cloneNode(true));
|
||||||
byId('v5SelectedActions').parentElement.append(form.querySelector('button[type="submit"]'));
|
byId('v5SelectedActions').parentElement.append(form.querySelector('button[type="submit"]'));
|
||||||
|
// Keep action selection and its fields together above the comment editor.
|
||||||
|
const composer = form.querySelector('.comment-composer');
|
||||||
|
const actionToolbar = byId('v5ActionToggle').parentElement;
|
||||||
|
const submitButton = form.querySelector('button[type="submit"]');
|
||||||
|
const submitRow = document.createElement('div');
|
||||||
|
submitRow.className = 'v5-submit-row';
|
||||||
|
submitRow.append(submitButton);
|
||||||
|
composer.before(actionToolbar, byId('v5ActionMenu'), byId('v5AssignmentFields'), byId('commentQuickTimeRow'));
|
||||||
|
composer.after(submitRow);
|
||||||
|
byId('v5ActionToggle').innerHTML = '<i class="bi bi-plus-lg" aria-hidden="true"></i><span>Handling</span>';
|
||||||
|
|
||||||
|
const commandFilterLabel = document.createElement('label');
|
||||||
|
commandFilterLabel.className = 'comment-category-check';
|
||||||
|
commandFilterLabel.title = 'Vis eller skjul /-kommandoer';
|
||||||
|
commandFilterLabel.innerHTML = '<input type="checkbox" id="commentShowCommands" checked aria-label="Vis /-kommandoer"><i class="bi bi-slash-lg" aria-hidden="true"></i><span id="commentSourceCountCommands" class="count">0</span>';
|
||||||
|
shell.querySelector('.comment-toolbar').append(commandFilterLabel);
|
||||||
|
const originalCommentFilters = window.applyCommentFilters;
|
||||||
|
window.applyCommentFilters = function (...args) {
|
||||||
|
originalCommentFilters(...args);
|
||||||
|
const items = [...byId('comments-container').querySelectorAll('.comment-item')];
|
||||||
|
const commandItems = items.filter(item => {
|
||||||
|
const raw = item.querySelector('.comment-body')?.dataset.commentRaw || '';
|
||||||
|
return /^\s*\/\/?(?:tid|tildel|forventet|undersag|mig|todo|afventer|løsning|deadline|udsat|fil|hjælp|timer|relation|prioritet|gruppe|mail|note)(?:\s|$)/im.test(raw)
|
||||||
|
|| /^(?:deadline|defer|priority):/i.test(raw.trim());
|
||||||
|
});
|
||||||
|
byId('commentSourceCountCommands').textContent = String(commandItems.length);
|
||||||
|
if (!byId('commentShowCommands').checked) commandItems.forEach(item => { item.hidden = true; });
|
||||||
|
const count = items.filter(item => !item.hidden).length;
|
||||||
|
byId('commentsVisibleCountBadge').textContent = `${count}/${items.length} vises`;
|
||||||
|
byId('commentFilterEmptyState')?.classList.toggle('d-none', count > 0 || items.length === 0);
|
||||||
|
};
|
||||||
|
byId('commentShowCommands').addEventListener('change', () => window.applyCommentFilters());
|
||||||
|
window.applyCommentFilters();
|
||||||
let assignSelected = false;
|
let assignSelected = false;
|
||||||
let groupId = config.groupId;
|
let groupId = config.groupId;
|
||||||
let userId = config.userId;
|
let userId = config.userId;
|
||||||
@ -249,7 +322,7 @@
|
|||||||
if (busy || pending) return;
|
if (busy || pending) return;
|
||||||
if (action[0] === 'assign') {
|
if (action[0] === 'assign') {
|
||||||
assignSelected = !assignSelected;
|
assignSelected = !assignSelected;
|
||||||
if (assignSelected && groupId) loadMembers();
|
if (assignSelected) loadMembers();
|
||||||
} else {
|
} else {
|
||||||
const checkbox = byId(action[3]);
|
const checkbox = byId(action[3]);
|
||||||
checkbox.checked = !checkbox.checked;
|
checkbox.checked = !checkbox.checked;
|
||||||
@ -297,30 +370,59 @@
|
|||||||
groupId = id; userId = null; members = []; renderGroups(); loadMembers();
|
groupId = id; userId = null; members = []; renderGroups(); loadMembers();
|
||||||
}, byId('v5GroupSearch').value);
|
}, byId('v5GroupSearch').value);
|
||||||
}
|
}
|
||||||
|
function employeeInitials(name) {
|
||||||
|
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
return (words.length > 1 ? words[0][0] + words[words.length - 1][0] : (words[0] || '?').slice(0, 2)).toLocaleUpperCase('da');
|
||||||
|
}
|
||||||
function renderUsers() {
|
function renderUsers() {
|
||||||
const items = [{id: null, name: 'Kun gruppen'}, ...members.map(member => ({id: member.user_id, name: member.display_name}))];
|
const host = byId('v5UserOptions');
|
||||||
renderPicker(byId('v5UserOptions'), items, userId, id => { userId = id; renderUsers(); }, byId('v5UserSearch').value);
|
host.replaceChildren();
|
||||||
|
const query = byId('v5UserSearch').value.trim().toLocaleLowerCase('da');
|
||||||
|
members.filter(member => `${member.display_name} ${employeeInitials(member.display_name)}`.toLocaleLowerCase('da').includes(query)).forEach(member => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.textContent = employeeInitials(member.display_name);
|
||||||
|
button.title = member.display_name;
|
||||||
|
button.setAttribute('aria-label', member.display_name);
|
||||||
|
button.setAttribute('aria-pressed', String(member.user_id === userId));
|
||||||
|
button.disabled = busy || Boolean(pending);
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
userId = member.user_id;
|
||||||
|
// Keep the current group when possible; otherwise infer it from
|
||||||
|
// the employee's membership without changing the API contract.
|
||||||
|
groupId = member.group_ids.includes(groupId) ? groupId : member.group_ids[0];
|
||||||
|
renderGroups(); renderUsers();
|
||||||
|
});
|
||||||
|
host.append(button);
|
||||||
|
});
|
||||||
|
if (!host.children.length) host.textContent = 'Ingen medarbejdere fundet';
|
||||||
renderActions();
|
renderActions();
|
||||||
}
|
}
|
||||||
async function loadMembers() {
|
async function loadMembers() {
|
||||||
const requestedGroupId = groupId;
|
|
||||||
const sequence = ++memberRequest;
|
const sequence = ++memberRequest;
|
||||||
membersLoading = true;
|
membersLoading = true;
|
||||||
byId('v5UserSearch').disabled = true;
|
byId('v5UserSearch').disabled = true;
|
||||||
byId('v5UserOptions').textContent = 'Henter medarbejdere…';
|
byId('v5UserOptions').textContent = 'Henter medarbejdere…';
|
||||||
try {
|
try {
|
||||||
let data = memberCache.get(requestedGroupId);
|
const results = await Promise.all(config.groups.map(async group => {
|
||||||
if (!data) {
|
let data = memberCache.get(group.id);
|
||||||
const response = await fetch(`/api/v1/sag/${config.caseId}/assignments?group_id=${requestedGroupId}`, {credentials: 'include'});
|
if (!data) {
|
||||||
if (!response.ok) throw new Error('Kunne ikke hente gruppens medarbejdere. Vælg gruppen igen for at prøve igen.');
|
const response = await fetch(`/api/v1/sag/${config.caseId}/assignments?group_id=${group.id}`, {credentials: 'include'});
|
||||||
data = await response.json();
|
if (!response.ok) throw new Error('Kunne ikke hente medarbejdere. Åbn Tildel igen for at prøve igen.');
|
||||||
memberCache.set(requestedGroupId, data);
|
data = await response.json();
|
||||||
}
|
memberCache.set(group.id, data);
|
||||||
|
}
|
||||||
|
return {groupId: group.id, members: data.members || []};
|
||||||
|
}));
|
||||||
if (sequence !== memberRequest) return;
|
if (sequence !== memberRequest) return;
|
||||||
members = data.members || [];
|
const employees = new Map();
|
||||||
|
results.forEach(result => result.members.forEach(member => {
|
||||||
|
if (!employees.has(member.user_id)) employees.set(member.user_id, {...member, group_ids: []});
|
||||||
|
employees.get(member.user_id).group_ids.push(result.groupId);
|
||||||
|
}));
|
||||||
|
members = [...employees.values()].sort((a,b) => a.display_name.localeCompare(b.display_name, 'da'));
|
||||||
if (!members.some(member => member.user_id === userId)) userId = null;
|
if (!members.some(member => member.user_id === userId)) userId = null;
|
||||||
byId('v5UserSearch').disabled = false;
|
byId('v5UserSearch').disabled = false;
|
||||||
byId('v5UserSearch').placeholder = 'Find medarbejder…';
|
|
||||||
renderUsers(); feedback('');
|
renderUsers(); feedback('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (sequence === memberRequest) { byId('v5UserOptions').textContent = ''; feedback(error.message, true); }
|
if (sequence === memberRequest) { byId('v5UserOptions').textContent = ''; feedback(error.message, true); }
|
||||||
|
|||||||
45
static/js/remote-api.js
Normal file
45
static/js/remote-api.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
export const base = '/api/v1/remote';
|
||||||
|
let csrf = '';
|
||||||
|
export async function api(path, data, method = data === undefined ? 'GET' : 'POST') {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(base + path, {method, credentials: 'same-origin', cache: 'no-store', signal: controller.signal,
|
||||||
|
headers: data === undefined ? {} : {'Content-Type': 'application/json', 'X-Remote-CSRF': csrf},
|
||||||
|
body: data === undefined ? undefined : JSON.stringify(data)});
|
||||||
|
if (response.redirected || (response.ok && !response.headers.get('Content-Type')?.includes('application/json'))) {
|
||||||
|
throw new Error('Hub sendte ikke det forventede svar. Prøver igen…');
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(typeof result.detail === 'string' ? result.detail : 'Kontrollér oplysningerne og prøv igen');
|
||||||
|
error.status = response.status;
|
||||||
|
error.retry = Number(response.headers.get('Retry-After') || 0);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status || error.message?.includes('forventede svar')) throw error;
|
||||||
|
throw new Error('Forbindelsen til Hub er afbrudt. Prøver igen…');
|
||||||
|
} finally { clearTimeout(timeout); }
|
||||||
|
}
|
||||||
|
export async function bootstrap() {
|
||||||
|
const result = await api('/bootstrap');
|
||||||
|
csrf = result.csrf;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
export function element(tag, text, className) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
node.textContent = text;
|
||||||
|
if (className) node.className = className;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRandomValues also works on HTTP local-network origins.
|
||||||
|
export function requestId() {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
70
static/js/remote-hub.js
Normal file
70
static/js/remote-hub.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// Loaded by normal Hub pages; dormant when the remote feature is disabled.
|
||||||
|
(async () => {
|
||||||
|
if (/^\/sag\/\d+\/v5\/?$/.test(location.pathname)) return;
|
||||||
|
const {api, bootstrap, requestId} = await import('/static/js/remote-api.js');
|
||||||
|
try { await bootstrap(); } catch (_) { return; }
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.style.cssText = 'position:fixed;right:12px;top:74px;z-index:1025;background:var(--bg-card,#fff);border:1px solid #ccd6df;border-radius:8px;padding:6px;display:flex;gap:8px;align-items:center;font-size:12px';
|
||||||
|
const link = document.createElement('a'); link.href = '/remote/manage'; link.textContent = 'Fjernbetjening';
|
||||||
|
const button = document.createElement('button'); button.className = 'btn btn-sm btn-outline-secondary'; button.textContent = 'Følg i denne fane';
|
||||||
|
panel.append(link, button); document.body.append(panel);
|
||||||
|
const previousTabId = sessionStorage.getItem('hubRemoteTab');
|
||||||
|
const tabId = requestId();
|
||||||
|
let following = sessionStorage.getItem('hubRemoteFollow') === 'yes';
|
||||||
|
// Browsers copy sessionStorage when duplicating a tab. Detect a living original
|
||||||
|
// so a duplicate cannot silently take over its remote connection.
|
||||||
|
if (typeof BroadcastChannel !== 'undefined') {
|
||||||
|
const channel = new BroadcastChannel('hub-remote-tabs');
|
||||||
|
let duplicate = false;
|
||||||
|
channel.onmessage = ({data}) => {
|
||||||
|
if (data.type === 'probe' && data.previous === tabId) channel.postMessage({type:'occupied',target:data.target});
|
||||||
|
if (data.type === 'occupied' && data.target === tabId) duplicate = true;
|
||||||
|
};
|
||||||
|
if (previousTabId) {
|
||||||
|
channel.postMessage({type:'probe',previous:previousTabId,target:tabId});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
if (duplicate) { following = false; sessionStorage.setItem('hubRemoteFollow', 'no'); }
|
||||||
|
}
|
||||||
|
window.addEventListener('pagehide', () => channel.close(), {once:true});
|
||||||
|
}
|
||||||
|
sessionStorage.setItem('hubRemoteTab', tabId);
|
||||||
|
let sequence = 0, pending = false;
|
||||||
|
let dirty = false;
|
||||||
|
document.addEventListener('input', event => { if (event.isTrusted && !panel.contains(event.target)) dirty = true; });
|
||||||
|
document.addEventListener('change', event => { if (event.isTrusted && !panel.contains(event.target)) dirty = true; });
|
||||||
|
const label = () => { button.textContent = following ? 'Stop med at følge' : 'Følg i denne fane'; };
|
||||||
|
async function enable() {
|
||||||
|
const result = await api('/follow', {tab_id: tabId, enable: true});
|
||||||
|
following = result.following; sequence = result.sequence; sessionStorage.setItem('hubRemoteFollow', following ? 'yes' : 'no'); label();
|
||||||
|
}
|
||||||
|
button.onclick = async () => {
|
||||||
|
if (pending) return;
|
||||||
|
pending = true; button.disabled = true;
|
||||||
|
try {
|
||||||
|
if (following) { await api('/follow', {tab_id: tabId, disable: true}); following = false; sessionStorage.setItem('hubRemoteFollow', 'no'); label(); }
|
||||||
|
else await enable();
|
||||||
|
} catch (error) { button.title = error.message; }
|
||||||
|
finally { pending = false; button.disabled = false; }
|
||||||
|
};
|
||||||
|
async function poll() {
|
||||||
|
if (!following || pending || document.hidden) return;
|
||||||
|
pending = true;
|
||||||
|
try {
|
||||||
|
const result = await api('/follow', {tab_id: tabId, after: sequence});
|
||||||
|
following = result.following; sequence = result.sequence; label();
|
||||||
|
if (!following) sessionStorage.setItem('hubRemoteFollow', 'no');
|
||||||
|
if (result.selection) {
|
||||||
|
const path = `/sag/${result.selection.case_id}`;
|
||||||
|
if (!location.pathname.match(new RegExp(`^${path}(/|$)`))) {
|
||||||
|
// Keep an in-progress user edit visible before leaving the page.
|
||||||
|
const editing = dirty || ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
|
||||||
|
if (!editing || confirm('Fjernbetjeningen har valgt en anden sag. Skift nu?')) location.assign(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) { button.title = 'Forbindelse afbrudt · ' + error.message; }
|
||||||
|
finally { pending = false; }
|
||||||
|
}
|
||||||
|
if (following) { try { await enable(); } catch (_) { following = false; } }
|
||||||
|
label(); setInterval(poll, 3000);
|
||||||
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); });
|
||||||
|
})();
|
||||||
45
static/js/remote-manage.js
Normal file
45
static/js/remote-manage.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import {api, bootstrap, element} from './remote-api.js';
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
let busy = false;
|
||||||
|
async function action(work) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true; $('notice').textContent = '';
|
||||||
|
document.querySelectorAll('button').forEach(b => { b.disabled = true; });
|
||||||
|
try { await work(); await load(); }
|
||||||
|
catch (error) { $('notice').textContent = error.message; }
|
||||||
|
finally { busy = false; document.querySelectorAll('button').forEach(b => { b.disabled = false; }); }
|
||||||
|
}
|
||||||
|
function showCode(result) { $('code').textContent = result.code; $('new-code').hidden = false; }
|
||||||
|
function hideCode() { $('code').textContent = ''; $('new-code').hidden = true; }
|
||||||
|
async function load() {
|
||||||
|
const result = await api('/admin');
|
||||||
|
$('remotes').replaceChildren(); $('devices').replaceChildren();
|
||||||
|
result.remotes.forEach(remote => {
|
||||||
|
const row = element('div', remote.name + (remote.revoked ? ' · Tilbagekaldt' : ''), 'row');
|
||||||
|
if (!remote.revoked) {
|
||||||
|
const rotate = element('button', 'Ny kode');
|
||||||
|
rotate.onclick = () => { if (confirm('Udskift koden og afbryd alle enheder til denne fjernbetjening?')) action(async () => showCode(await api(`/admin/remotes/${remote.id}/rotate`, {}))); };
|
||||||
|
const revoke = element('button', 'Tilbagekald', 'secondary');
|
||||||
|
revoke.onclick = () => { if (confirm('Tilbagekald denne fjernbetjening og dens enheder?')) action(() => api(`/admin/remotes/${remote.id}`, {}, 'DELETE')); };
|
||||||
|
row.append(rotate, revoke);
|
||||||
|
}
|
||||||
|
$('remotes').append(row);
|
||||||
|
});
|
||||||
|
result.devices.forEach(device => {
|
||||||
|
const row = element('div', `${device.name} · Senest aktiv ${new Date(device.last_seen_at).toLocaleString('da-DK')} · ${device.revoked ? 'Tilbagekaldt' : 'Udløber ' + new Date(device.expires_at).toLocaleDateString('da-DK')}`, 'row');
|
||||||
|
if (!device.revoked) {
|
||||||
|
const revoke = element('button', 'Afbryd enhed', 'secondary');
|
||||||
|
revoke.onclick = () => action(() => api(`/admin/devices/${device.id}`, {}, 'DELETE')); row.append(revoke);
|
||||||
|
}
|
||||||
|
$('devices').append(row);
|
||||||
|
});
|
||||||
|
if (!result.remotes.length) $('remotes').textContent = 'Ingen fjernbetjeninger endnu';
|
||||||
|
if (!result.devices.length) $('devices').textContent = 'Ingen forbundne enheder';
|
||||||
|
if (!result.pin_set) $('notice').textContent = 'Opret din 4-cifrede PIN før din første fjernbetjening.';
|
||||||
|
}
|
||||||
|
$('pin-form').onsubmit = event => { event.preventDefault(); action(async () => { await api('/admin/pin', {pin: $('pin').value}, 'PUT'); $('pin').value = ''; }); };
|
||||||
|
$('create-form').onsubmit = event => { event.preventDefault(); action(async () => showCode(await api('/admin/remotes', {name: $('name').value}))); };
|
||||||
|
$('hide-code').onclick = hideCode;
|
||||||
|
window.addEventListener('pagehide', hideCode);
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.hidden) hideCode(); });
|
||||||
|
try { await bootstrap(); await load(); } catch (error) { $('notice').textContent = error.message; }
|
||||||
339
static/js/remote-mobile.js
Normal file
339
static/js/remote-mobile.js
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
import {api, bootstrap, element, requestId} from './remote-api.js?v=20261014-day';
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
let snapshot, loadedAt, online = false, busy = false, loginStatus = 'connect', kind = 'new', offset = 0, selection;
|
||||||
|
let blockedUntil = 0, lastCases = 0, polling = false, lastDay = 0;
|
||||||
|
let uncertain = null, pendingOwner = null;
|
||||||
|
let contactOffset = 0, pendingCall = null;
|
||||||
|
const renderedLists = new Map();
|
||||||
|
function listChanged(key, data) {
|
||||||
|
const next = JSON.stringify(data);
|
||||||
|
if (renderedLists.get(key) === next) return false;
|
||||||
|
renderedLists.set(key, next);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try { pendingCall = JSON.parse(sessionStorage.getItem('hubRemoteCall') || 'null'); } catch (_) {}
|
||||||
|
try { const stored = JSON.parse(sessionStorage.getItem('hubRemotePending') || 'null'); uncertain = stored?.payload; pendingOwner = stored?.user_id; } catch (_) {}
|
||||||
|
const notice = text => { $('notice').textContent = text; };
|
||||||
|
const serverNow = () => snapshot?.server_time ? new Date(snapshot.server_time).getTime() + performance.now() - loadedAt : Date.now();
|
||||||
|
function buttons() {
|
||||||
|
document.querySelectorAll('#controls button').forEach(b => { b.disabled = !online || busy || !!uncertain; });
|
||||||
|
document.querySelectorAll('[data-phone-call]').forEach(b => {b.disabled ||= !!pendingCall;});
|
||||||
|
$('uncertain').hidden = !uncertain;
|
||||||
|
$('retry-command').disabled = !online || busy;
|
||||||
|
$('check-command').disabled = !online || busy;
|
||||||
|
const active = snapshot?.timer.active.active;
|
||||||
|
document.querySelector('[data-action="start"]').disabled ||= !snapshot?.active_case || active;
|
||||||
|
document.querySelector('[data-action="pause"]').disabled ||= !active;
|
||||||
|
document.querySelector('[data-action="stop"]').disabled ||= !active;
|
||||||
|
}
|
||||||
|
function deadline(row) {
|
||||||
|
if (!row?.deadline) return 'Ingen deadline';
|
||||||
|
const seconds = Math.floor((new Date(row.deadline) - serverNow()) / 1000);
|
||||||
|
return `${seconds < 0 ? 'Deadline overskredet' : 'Deadline om ' + Math.ceil(seconds / 60) + ' min'} · ${new Date(row.deadline).toLocaleString('da-DK', {timeZone:'Europe/Copenhagen'})}`;
|
||||||
|
}
|
||||||
|
function login(status) {
|
||||||
|
loginStatus = status; online = false; snapshot = null;
|
||||||
|
$('login').hidden = false; $('controls').hidden = true;
|
||||||
|
$('login').scrollIntoView({block:'start', behavior:'instant'});
|
||||||
|
$('code-label').hidden = status === 'locked'; $('code').required = status !== 'locked';
|
||||||
|
$('device-label').hidden = status === 'locked';
|
||||||
|
$('login-title').textContent = status === 'locked' ? 'Lås op med din daglige PIN' : 'Forbind din enhed';
|
||||||
|
$('login-submit').textContent = status === 'locked' ? 'Lås op' : 'Forbind';
|
||||||
|
$('connection').textContent = status === 'locked' ? 'Låst' : 'Ikke forbundet';
|
||||||
|
$('connection').style.setProperty('--connection-dot', '#8da1b6');
|
||||||
|
buttons();
|
||||||
|
}
|
||||||
|
function failure(error) {
|
||||||
|
online = false; buttons(); notice(error.message);
|
||||||
|
$('connection').textContent = 'Ingen forbindelse · timeren fortsætter i Hub';
|
||||||
|
$('connection').style.setProperty('--connection-dot', '#f9c26b');
|
||||||
|
if (error.status === 423) login('locked');
|
||||||
|
if (error.status === 401) login('connect');
|
||||||
|
if (error.retry) blockedUntil = Date.now() + error.retry * 1000;
|
||||||
|
}
|
||||||
|
function render() {
|
||||||
|
$('login').hidden = true; $('controls').hidden = false;
|
||||||
|
$('connection').textContent = 'Forbundet · ' + snapshot.user_name;
|
||||||
|
$('connection').style.setProperty('--connection-dot', '#5ee5bc');
|
||||||
|
$('desktop').textContent = snapshot.desktop_connected ? 'Hub-fane forbundet' : 'Computer ikke forbundet';
|
||||||
|
$('desktop').classList.toggle('connected', snapshot.desktop_connected);
|
||||||
|
document.querySelector('.hero').classList.toggle('running', snapshot.timer.active.active);
|
||||||
|
$('active-case').textContent = snapshot.active_case ? `#${snapshot.active_case.id} · ${snapshot.active_case.titel}` : 'Vælg en sag';
|
||||||
|
$('deadline').textContent = deadline(snapshot.active_case);
|
||||||
|
$('deadline').className = snapshot.active_case?.deadline_state || '';
|
||||||
|
$('timer-case').textContent = snapshot.timer.active.active ? `Timer: #${snapshot.timer.active.sag_id} · ${snapshot.timer.active.sag_navn}` : 'Ingen timer kører';
|
||||||
|
$('today').textContent = `${Math.floor(snapshot.today_minutes / 60)} t ${snapshot.today_minutes % 60} min`;
|
||||||
|
$('remote-date').textContent = new Intl.DateTimeFormat('da-DK', {day:'numeric',month:'long',timeZone:'Europe/Copenhagen'}).format(new Date(serverNow()));
|
||||||
|
const unread = snapshot.messages.list.filter(m => m.is_unread).length;
|
||||||
|
$('message-count').textContent = unread; $('message-count').hidden = !unread;
|
||||||
|
document.querySelectorAll('.case').forEach(b => b.classList.toggle('selected', Number(b.dataset.caseId) === snapshot.active_case?.id));
|
||||||
|
if (listChanged('paused', snapshot.timer.paused)) {
|
||||||
|
$('paused').replaceChildren();
|
||||||
|
snapshot.timer.paused.forEach(timer => {
|
||||||
|
const row = element('div', `${timer.sag_navn} · ${timer.elapsed_hhmmss}`, 'row');
|
||||||
|
const resume = element('button', 'Genoptag'); resume.onclick = () => send('resume', {time_id: timer.time_entry_id});
|
||||||
|
resume.disabled = snapshot.timer.active.active;
|
||||||
|
const stop = element('button', 'Stop og gem'); stop.onclick = () => send('stop', {time_id: timer.time_entry_id});
|
||||||
|
row.append(resume, stop); $('paused').append(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (listChanged('messages', snapshot.messages.list)) {
|
||||||
|
$('messages').replaceChildren();
|
||||||
|
snapshot.messages.list.forEach(message => {
|
||||||
|
const row = element('div', '', 'row');
|
||||||
|
row.append(element('strong', `${message.from}${message.is_unread ? ' · Ulæst' : ''}`), element('p', message.text));
|
||||||
|
if (message.requires_manual_ack && !message.is_acknowledged && !message.is_own) {
|
||||||
|
const ack = element('button', 'Bekræft læst'); ack.onclick = () => send('ack', {message_id: message.id}); row.append(ack);
|
||||||
|
}
|
||||||
|
$('messages').append(row);
|
||||||
|
});
|
||||||
|
if (!snapshot.messages.list.length) $('messages').textContent = 'Ingen beskeder';
|
||||||
|
}
|
||||||
|
buttons();
|
||||||
|
document.querySelectorAll('#paused button:first-of-type').forEach(b => { b.disabled ||= snapshot.timer.active.active; });
|
||||||
|
}
|
||||||
|
async function sync() {
|
||||||
|
if (polling || busy || document.hidden || loginStatus !== 'ready') return;
|
||||||
|
polling = true;
|
||||||
|
try { const reconnecting = !online; const next = await api('/state');
|
||||||
|
if (!next?.timer?.active || !Array.isArray(next.timer.paused) || !Array.isArray(next.messages?.list)) throw new Error('Hub sendte et ufuldstændigt svar. Prøver igen…');
|
||||||
|
snapshot = next; loadedAt = performance.now(); online = true;
|
||||||
|
if (reconnecting && !uncertain) notice('');
|
||||||
|
if (uncertain && pendingOwner !== snapshot.user_id) { clearPending(); notice('Den tidligere handling tilhørte en anden bruger og er fjernet fra denne enhed.'); }
|
||||||
|
render();
|
||||||
|
if (Date.now() - lastDay > 30000) await syncDay();
|
||||||
|
await syncPhone();
|
||||||
|
if (Date.now() - lastCases > 15000) await loadCases();
|
||||||
|
} catch (error) { failure(error); } finally { polling = false; }
|
||||||
|
}
|
||||||
|
function clearPending() { uncertain = null; pendingOwner = null; sessionStorage.removeItem('hubRemotePending'); buttons(); }
|
||||||
|
async function send(action, extra = {}, retry = null) {
|
||||||
|
if (busy || !online || !snapshot || (uncertain && !retry)) return;
|
||||||
|
const data = retry || {request_id: requestId(), version: snapshot.version, timer_version: snapshot.timer_version,
|
||||||
|
action, case_id: snapshot.active_case?.id, description: $('description').value.trim(), ...extra};
|
||||||
|
if (['stop', 'manual'].includes(action) && !data.description) { notice('Angiv beskrivelse først'); $('description').focus(); return; }
|
||||||
|
busy = true; buttons(); notice('');
|
||||||
|
// Preserve the same idempotency key through an uncertain network response and reload.
|
||||||
|
// Nothing is automatically replayed after losing connection.
|
||||||
|
pendingOwner = snapshot.user_id;
|
||||||
|
sessionStorage.setItem('hubRemotePending', JSON.stringify({user_id: pendingOwner, payload: data}));
|
||||||
|
try { await api('/command', data); clearPending(); if (action === 'manual' || action === 'stop') $('description').value = ''; }
|
||||||
|
catch (error) {
|
||||||
|
notice(error.message);
|
||||||
|
if (!error.status || error.status >= 500) uncertain = data;
|
||||||
|
else clearPending();
|
||||||
|
if (error.status !== 409) failure(error);
|
||||||
|
}
|
||||||
|
finally { busy = false; await sync(); buttons(); }
|
||||||
|
}
|
||||||
|
$('retry-command').onclick = () => send(uncertain.action, {}, uncertain);
|
||||||
|
$('check-command').onclick = async () => {
|
||||||
|
if (!uncertain || busy) return;
|
||||||
|
try {
|
||||||
|
const result = await api('/command-result/' + uncertain.request_id);
|
||||||
|
if (result.completed) { clearPending(); notice('Handlingen blev gemt i Hub.'); await sync(); }
|
||||||
|
else notice('Hub har ikke registreret handlingen. Brug Prøv samme handling igen.');
|
||||||
|
} catch (error) { failure(error); }
|
||||||
|
};
|
||||||
|
async function select(row) {
|
||||||
|
if (snapshot?.timer.active.active && snapshot.timer.active.sag_id !== row.id) {
|
||||||
|
selection = row; $('switch-dialog').showModal();
|
||||||
|
} else await send('select', {case_id: row.id});
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-switch]').forEach(b => b.onclick = async () => {
|
||||||
|
$('switch-dialog').close();
|
||||||
|
if (b.dataset.switch !== 'cancel') await send('select', {case_id: selection.id, switch_timer: b.dataset.switch === 'timer'});
|
||||||
|
});
|
||||||
|
async function loadCases(append = false) {
|
||||||
|
const data = await api(`/cases?kind=${kind}&q=${encodeURIComponent($('search').value)}&offset=${offset}`);
|
||||||
|
if (!append && !listChanged('cases', {kind, offset, query:$('search').value, data})) {lastCases=Date.now();buttons();return;}
|
||||||
|
if (append) renderedLists.delete('cases');
|
||||||
|
if (!append) $('cases').replaceChildren();
|
||||||
|
data.items.forEach(row => {
|
||||||
|
const button = element('button', `#${row.id} · ${row.titel}`, 'case');
|
||||||
|
button.dataset.caseId = row.id;
|
||||||
|
button.classList.toggle('selected', row.id === snapshot?.active_case?.id);
|
||||||
|
button.append(element('small', `${row.customer_name || ''} · ${row.status} · ${deadline(row)}`, row.deadline_state));
|
||||||
|
button.onclick = () => select(row); $('cases').append(button);
|
||||||
|
});
|
||||||
|
if (!data.items.length && !append) $('cases').textContent = 'Ingen sager';
|
||||||
|
$('more').hidden = !data.has_more; lastCases = Date.now(); buttons();
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-action]').forEach(b => b.onclick = () => send(b.dataset.action));
|
||||||
|
document.querySelectorAll('[data-kind]').forEach(b => b.onclick = () => {
|
||||||
|
kind = b.dataset.kind; offset = 0;
|
||||||
|
document.querySelectorAll('[data-kind]').forEach(x => x.setAttribute('aria-pressed', x === b));
|
||||||
|
loadCases().catch(failure);
|
||||||
|
});
|
||||||
|
$('search-form').onsubmit = event => { event.preventDefault(); kind = 'search'; offset = 0; loadCases().catch(failure); };
|
||||||
|
$('more').onclick = () => { offset += 50; loadCases(true).catch(failure); };
|
||||||
|
$('manual-form').onsubmit = event => { event.preventDefault(); send('manual', {minutes: Number($('minutes').value)}); };
|
||||||
|
$('login-form').onsubmit = async event => {
|
||||||
|
event.preventDefault(); if (Date.now() < blockedUntil || busy) return;
|
||||||
|
busy = true; $('login-submit').disabled = true;
|
||||||
|
try { await api(loginStatus === 'locked' ? '/unlock' : '/connect', {code: $('code').value, pin: $('pin').value, name: $('device').value || 'Mobil / tablet'});
|
||||||
|
loginStatus = 'ready'; $('pin').value = ''; $('code').value = ''; notice('');
|
||||||
|
} catch (error) { notice(error.message); if (error.retry) blockedUntil = Date.now() + error.retry * 1000; }
|
||||||
|
finally { busy = false; await sync(); }
|
||||||
|
};
|
||||||
|
$('logout').onclick = async () => { try { await api('/logout', {}); login('connect'); } catch (error) { failure(error); } };
|
||||||
|
setInterval(() => {
|
||||||
|
const seconds = Math.max(0, Math.ceil((blockedUntil - Date.now()) / 1000));
|
||||||
|
$('login-submit').disabled = busy || seconds > 0;
|
||||||
|
if (seconds) $('login-submit').textContent = `Prøv igen om ${seconds} sek`;
|
||||||
|
else $('login-submit').textContent = loginStatus === 'locked' ? 'Lås op' : 'Forbind';
|
||||||
|
if (snapshot) {
|
||||||
|
const active = snapshot.timer.active;
|
||||||
|
const elapsed = active.active ? active.elapsed + Math.floor((performance.now() - loadedAt) / 1000) : 0;
|
||||||
|
$('elapsed').textContent = [Math.floor(elapsed/3600), Math.floor(elapsed/60)%60, elapsed%60].map(x => String(x).padStart(2,'0')).join(':');
|
||||||
|
$('deadline').textContent = deadline(snapshot.active_case);
|
||||||
|
const left = snapshot.active_case?.deadline ? (new Date(snapshot.active_case.deadline) - serverNow()) / 1000 : null;
|
||||||
|
$('deadline').className = left === null ? '' : left < 0 ? 'overdue' : left <= 3600 ? 'warning' : '';
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
setInterval(sync, 3000);
|
||||||
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) sync(); });
|
||||||
|
window.addEventListener('online', sync);
|
||||||
|
window.addEventListener('pageshow', () => sync());
|
||||||
|
window.addEventListener('offline', () => failure(new Error('Forbindelsen er afbrudt')));
|
||||||
|
try { const result = await bootstrap(); loginStatus = result.status; if (loginStatus === 'ready') await sync(); else login(loginStatus); }
|
||||||
|
catch (error) { failure(error); }
|
||||||
|
|
||||||
|
async function syncDay() {
|
||||||
|
try {
|
||||||
|
const data = await api('/day');
|
||||||
|
if (!Array.isArray(data.items)) throw new Error('Dagens plan kunne ikke læses');
|
||||||
|
if (listChanged('day', data)) {
|
||||||
|
const host = $('day-items');host.replaceChildren();
|
||||||
|
data.items.forEach(item => {
|
||||||
|
const row=element('div','','day-plan-item');
|
||||||
|
const time=value=>new Intl.DateTimeFormat('da-DK',{hour:'2-digit',minute:'2-digit',timeZone:'Europe/Copenhagen'}).format(new Date(value));
|
||||||
|
const detail=element('div','');
|
||||||
|
detail.append(element('strong',item.title||'Planlagt opgave'),element('small',item.customer_name||'Intern opgave'));
|
||||||
|
if (item.case_id) {
|
||||||
|
const button=element('button',`Vælg sag #${item.case_id}`);
|
||||||
|
button.onclick=()=>select({id:item.case_id});detail.append(button);
|
||||||
|
}
|
||||||
|
row.append(element('span',`${time(item.starts_at)}–${time(item.ends_at)}`,'day-plan-time'),detail);host.append(row);
|
||||||
|
});
|
||||||
|
if(!data.items.length)host.textContent='Ingen planlagte opgaver i dag';
|
||||||
|
}
|
||||||
|
$('day-status').textContent='';lastDay=Date.now();
|
||||||
|
} catch(error) {
|
||||||
|
$('day-status').textContent='Dagens plan kunne ikke opdateres. Prøver igen…';
|
||||||
|
if(error.status===401||error.status===423)throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let wakeLock=null,keepAwake=false;
|
||||||
|
async function holdScreen() {
|
||||||
|
if(!keepAwake||document.hidden||wakeLock)return;
|
||||||
|
try {
|
||||||
|
if(!navigator.wakeLock)throw new Error('unsupported');
|
||||||
|
wakeLock=await navigator.wakeLock.request('screen');
|
||||||
|
wakeLock.addEventListener('release',()=>{wakeLock=null;$('screen-awake-status').textContent='Skærmlåsen blev frigivet';});
|
||||||
|
$('screen-awake-status').textContent='Skærmen holdes tændt, mens remote er synlig';
|
||||||
|
} catch(_) {$('screen-awake-status').textContent='Browseren kunne ikke holde skærmen tændt';}
|
||||||
|
}
|
||||||
|
$('screen-awake').onclick=async()=>{
|
||||||
|
keepAwake=!keepAwake;$('screen-awake').textContent=keepAwake?'Tillad skærmen at slukke':'Hold skærmen tændt';
|
||||||
|
if(keepAwake)await holdScreen();else {await wakeLock?.release();wakeLock=null;$('screen-awake-status').textContent='';}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange',()=>{if(!document.hidden){lastDay=0;holdScreen();}});
|
||||||
|
|
||||||
|
async function syncPhone() {
|
||||||
|
try {
|
||||||
|
const data = await api('/telephony');
|
||||||
|
$('phone-card').hidden = !data.allowed;
|
||||||
|
document.querySelector('.remote-nav a[href="#phone-card"]').hidden = !data.allowed;
|
||||||
|
$('call-count').textContent = data.incoming?.length || 0; $('call-count').hidden = !data.incoming?.length;
|
||||||
|
if (listChanged('incoming', data.incoming || [])) {
|
||||||
|
$('incoming-calls').replaceChildren();
|
||||||
|
(data.incoming || []).forEach(call => {
|
||||||
|
const row = element('div', '', 'incoming-call');
|
||||||
|
row.append(element('strong', call.contact_name || call.caller_number || 'Ukendt nummer'),
|
||||||
|
element('p', [call.company_name, call.caller_number, (call.ringing_phones || []).join(', ')].filter(Boolean).join(' · ')));
|
||||||
|
$('incoming-calls').append(row);
|
||||||
|
});
|
||||||
|
if (!data.incoming?.length) $('incoming-calls').textContent = 'Ingen indgående opkald';
|
||||||
|
}
|
||||||
|
if (pendingCall && pendingCall.user_id !== snapshot.user_id) {
|
||||||
|
pendingCall = null; sessionStorage.removeItem('hubRemoteCall');
|
||||||
|
}
|
||||||
|
$('check-call').hidden = !pendingCall; $('clear-call').hidden = !pendingCall;
|
||||||
|
if (pendingCall) $('phone-notice').textContent = 'Svaret på opkaldet mangler. Kontrollér status og din telefon før et nyt opkald.';
|
||||||
|
} catch (error) {
|
||||||
|
renderedLists.delete('incoming');
|
||||||
|
$('incoming-calls').textContent = 'Opkaldsvisningen er midlertidigt utilgængelig';
|
||||||
|
if (error.status === 401 || error.status === 423) throw error;
|
||||||
|
}
|
||||||
|
buttons();
|
||||||
|
}
|
||||||
|
async function loadContacts(append = false) {
|
||||||
|
const data = await api(`/contacts?q=${encodeURIComponent($('contact-search').value)}&offset=${contactOffset}`);
|
||||||
|
if (!append) $('contact-results').replaceChildren();
|
||||||
|
data.items.forEach(contact => {
|
||||||
|
const row = element('div', '', 'row');
|
||||||
|
row.append(element('strong', [contact.first_name, contact.last_name].filter(Boolean).join(' ')),
|
||||||
|
element('p', [...(contact.company_names || []), contact.email].filter(Boolean).join(' · ')));
|
||||||
|
['phone', 'mobile'].forEach(field => {
|
||||||
|
if (!contact[field]) return;
|
||||||
|
const button = element('button', `Ring ${field === 'mobile' ? 'mobil' : 'telefon'} · ${contact[field]}`);
|
||||||
|
button.dataset.phoneCall = 'yes';
|
||||||
|
button.onclick = () => dialContact(contact.id, field);
|
||||||
|
row.append(button);
|
||||||
|
});
|
||||||
|
$('contact-results').append(row);
|
||||||
|
});
|
||||||
|
if (!data.items.length && !append) $('contact-results').textContent = 'Ingen kontakter fundet';
|
||||||
|
$('contact-more').hidden = !data.has_more; buttons();
|
||||||
|
}
|
||||||
|
async function dialContact(contact_id, field) {
|
||||||
|
if (!online || busy || pendingCall) return;
|
||||||
|
busy = true; buttons();
|
||||||
|
const payload = {request_id: requestId(), contact_id, field};
|
||||||
|
pendingCall = {user_id: snapshot.user_id, payload};
|
||||||
|
sessionStorage.setItem('hubRemoteCall', JSON.stringify(pendingCall));
|
||||||
|
try {
|
||||||
|
const result = await api('/call', payload);
|
||||||
|
$('phone-notice').textContent = result.message;
|
||||||
|
if (result.status !== 'uncertain') { pendingCall = null; sessionStorage.removeItem('hubRemoteCall'); }
|
||||||
|
} catch (error) {
|
||||||
|
$('phone-notice').textContent = error.status ? error.message : 'Svaret mangler. Kontrollér telefonen før et nyt opkald.';
|
||||||
|
if (error.status) { pendingCall = null; sessionStorage.removeItem('hubRemoteCall'); }
|
||||||
|
} finally { busy = false; $('check-call').hidden = !pendingCall; $('clear-call').hidden = !pendingCall; buttons(); }
|
||||||
|
}
|
||||||
|
$('contact-search-form').onsubmit = event => {event.preventDefault(); contactOffset = 0; loadContacts().catch(error => {$('phone-notice').textContent = error.message;});};
|
||||||
|
$('contact-more').onclick = () => {contactOffset += 50; loadContacts(true).catch(error => {$('phone-notice').textContent = error.message;});};
|
||||||
|
$('check-call').onclick = async () => {
|
||||||
|
if (!pendingCall || !online || busy) return;
|
||||||
|
busy = true; buttons();
|
||||||
|
try {
|
||||||
|
// Read status only; never submit an old call while reconnecting.
|
||||||
|
const result = await api(`/call/${pendingCall.payload.request_id}`);
|
||||||
|
$('phone-notice').textContent = result.message;
|
||||||
|
if (result.status !== 'uncertain') {pendingCall = null; sessionStorage.removeItem('hubRemoteCall');}
|
||||||
|
} catch (error) {$('phone-notice').textContent = error.message;}
|
||||||
|
finally {busy = false; $('check-call').hidden = !pendingCall; $('clear-call').hidden = !pendingCall; buttons();}
|
||||||
|
};
|
||||||
|
|
||||||
|
$('clear-call').onclick = () => {
|
||||||
|
pendingCall = null; sessionStorage.removeItem('hubRemoteCall');
|
||||||
|
$('check-call').hidden = true; $('clear-call').hidden = true;
|
||||||
|
$('phone-notice').textContent = 'Du kan nu starte et nyt opkald'; buttons();
|
||||||
|
};
|
||||||
|
|
||||||
|
const navLinks = [...document.querySelectorAll('.remote-nav a')];
|
||||||
|
function highlightNavigation(id) {
|
||||||
|
navLinks.forEach(link => {
|
||||||
|
if (link.hash === '#' + id) link.setAttribute('aria-current', 'location');
|
||||||
|
else link.removeAttribute('aria-current');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
navLinks.forEach(link => link.addEventListener('click', () => highlightNavigation(link.hash.slice(1))));
|
||||||
|
if ('IntersectionObserver' in window) {
|
||||||
|
const observer = new IntersectionObserver(entries => {
|
||||||
|
const visible = entries.filter(entry => entry.isIntersecting && !entry.target.hidden);
|
||||||
|
if (visible.length) highlightNavigation(visible[0].target.id);
|
||||||
|
}, {rootMargin:'-15% 0px -60% 0px',threshold:0});
|
||||||
|
navLinks.forEach(link => observer.observe(document.querySelector(link.hash)));
|
||||||
|
}
|
||||||
165
tests/browser/remote_control.cjs
Normal file
165
tests/browser/remote_control.cjs
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
/* Isolated mobile/tablet UI tests: all requests are mocked, no Hub data is changed.
|
||||||
|
* BMC_PLAYWRIGHT_MODULE=/path/to/playwright node tests/browser/remote_control.cjs
|
||||||
|
* BMC_REMOTE_BROWSER=webkit tests the Safari/WebKit engine.
|
||||||
|
*/
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const pw = require(process.env.BMC_PLAYWRIGHT_MODULE || 'playwright');
|
||||||
|
const engine = process.env.BMC_REMOTE_BROWSER || 'chromium';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await pw[engine].launch(engine === 'chromium'
|
||||||
|
? {headless:true, executablePath:process.env.BMC_CHROME_EXECUTABLE || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}
|
||||||
|
: {headless:true});
|
||||||
|
try {
|
||||||
|
for (const viewport of [{width:390,height:844}, {width:820,height:1180}]) {
|
||||||
|
const context = await browser.newContext({viewport});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [], writes = [], completed = new Map();
|
||||||
|
let disconnected = false, dropReply = false;
|
||||||
|
const cases = [11,12].map(id => ({id,titel:'Testsag '+id,status:'Ny',deadline:null,deadline_state:'none'}));
|
||||||
|
let state = {user_id:1,user_name:'Testbruger',version:0,timer_version:'a'.repeat(64),active_case:cases[0],desktop_connected:false,
|
||||||
|
server_time:new Date().toISOString(),
|
||||||
|
timer:{active:{active:false,elapsed:0},paused:[]},today_minutes:0,messages:{count:1,list:[{id:1,from:'Kollega',text:'Testbesked',is_unread:true}]}};
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
await page.route('**/*', async route => {
|
||||||
|
const req = route.request(), path = new URL(req.url()).pathname;
|
||||||
|
if (path === '/remote') return route.fulfill({contentType:'text/html',body:fs.readFileSync('app/modules/remote/mobile.html','utf8')});
|
||||||
|
if (path.startsWith('/static/')) return route.fulfill({contentType:path.endsWith('.css')?'text/css':'application/javascript',body:fs.readFileSync('.'+path,'utf8')});
|
||||||
|
if (disconnected) return route.abort('internetdisconnected');
|
||||||
|
let result = {};
|
||||||
|
if (path.endsWith('/bootstrap')) result = {csrf:'test-csrf',status:'connect'};
|
||||||
|
if (path.endsWith('/connect')) {writes.push(req.postDataJSON());result={ok:true};}
|
||||||
|
if (path.endsWith('/state')) result = state;
|
||||||
|
if (path.endsWith('/telephony')) result = {allowed:true,incoming:[{call_id:'incoming-1',contact_name:'Indgående testkontakt',caller_number:'12345678',company_name:'Testfirma',ringing_phones:['Support']}]};
|
||||||
|
if (path.endsWith('/contacts')) result = {items:[{id:1,first_name:'Test',last_name:'Kontakt',mobile:'87654321',company_names:['Testfirma']}],has_more:false};
|
||||||
|
if (path.endsWith('/call')) {writes.push(req.postDataJSON());result={status:'ok',message:'Opkald startet på din VoIP-telefon'};}
|
||||||
|
if (path.endsWith('/cases')) result = {items:cases,has_more:false};
|
||||||
|
if (path.includes('/command-result/')) result = {completed:completed.has(path.split('/').pop())};
|
||||||
|
if (path.endsWith('/command')) {
|
||||||
|
const data = req.postDataJSON(); writes.push(data);
|
||||||
|
if (!completed.has(data.request_id)) {
|
||||||
|
state.version++;
|
||||||
|
if (data.action === 'start') state.timer.active={active:true,sag_id:data.case_id,sag_navn:'Testsag '+data.case_id,time_entry_id:1,elapsed:10};
|
||||||
|
if (data.action === 'select') {state.active_case=cases.find(c=>c.id===data.case_id);if(data.switch_timer)state.timer.active.sag_id=data.case_id;}
|
||||||
|
if (data.action === 'pause') {state.timer.paused=[{sag_navn:'Testsag',time_entry_id:1,elapsed_hhmmss:'00:00:10'}];state.timer.active.active=false;}
|
||||||
|
if (data.action === 'resume') {state.timer.active.active=true;state.timer.paused=[];}
|
||||||
|
if (data.action === 'stop') state.timer.active.active=false;
|
||||||
|
if (data.action === 'manual') state.today_minutes+=data.minutes;
|
||||||
|
completed.set(data.request_id,{ok:true});
|
||||||
|
}
|
||||||
|
result=completed.get(data.request_id);
|
||||||
|
if(dropReply){dropReply=false;return route.abort('internetdisconnected');}
|
||||||
|
}
|
||||||
|
return route.fulfill({contentType:'application/json',body:JSON.stringify(result)});
|
||||||
|
});
|
||||||
|
await page.goto('http://remote.test/remote');
|
||||||
|
await page.locator('#code').fill('000000000042');
|
||||||
|
await page.locator('#pin').fill('1234');
|
||||||
|
await page.getByRole('button',{name:'Forbind',exact:true}).click();
|
||||||
|
await page.locator('#controls').waitFor({state:'visible'});
|
||||||
|
assert.equal(writes[0].code,'000000000042');
|
||||||
|
assert.equal(await page.locator('#deadline').textContent(),'Ingen deadline');
|
||||||
|
assert.equal(await page.locator('#messages').textContent().then(s=>s.includes('Testbesked')),true);
|
||||||
|
await page.getByRole('button',{name:'Start timer',exact:true}).evaluate(b=>{b.click();b.click();});
|
||||||
|
await page.waitForFunction(()=>document.querySelector('[data-action="pause"]').disabled===false);
|
||||||
|
assert.equal(writes.filter(x=>x.action==='start').length,1);
|
||||||
|
await page.getByText('Indgående testkontakt',{exact:true}).waitFor();
|
||||||
|
await page.locator('#contact-search').fill('Kontakt');
|
||||||
|
await page.getByRole('button',{name:'Søg kontakter',exact:true}).click();
|
||||||
|
await page.getByRole('button',{name:'Ring mobil · 87654321',exact:true}).evaluate(b=>{b.click();b.click();});
|
||||||
|
await page.getByText('Opkald startet på din VoIP-telefon',{exact:true}).waitFor();
|
||||||
|
assert.equal(writes.filter(x=>x.contact_id===1).length,1);
|
||||||
|
|
||||||
|
await page.locator('.case').filter({hasText:'#12'}).click();
|
||||||
|
await page.getByRole('button',{name:'Kun skift sag',exact:true}).click();
|
||||||
|
await page.waitForFunction(()=>document.querySelector('#active-case').textContent.startsWith('#12'));
|
||||||
|
assert.equal(state.timer.active.sag_id,11);
|
||||||
|
await page.getByRole('button',{name:'Pause',exact:true}).click();
|
||||||
|
await page.getByRole('button',{name:'Genoptag',exact:true}).waitFor();
|
||||||
|
await page.getByRole('button',{name:'Genoptag',exact:true}).click();
|
||||||
|
await page.waitForFunction(()=>document.querySelector('[data-action="pause"]').disabled===false);
|
||||||
|
await page.locator('#description').fill('Testarbejde');
|
||||||
|
await page.getByRole('button',{name:'Stop og gem tid',exact:true}).click();
|
||||||
|
await page.waitForFunction(()=>document.querySelector('[data-action="start"]').disabled===false);
|
||||||
|
await page.locator('#description').fill('Manuel test');await page.locator('#minutes').fill('17');
|
||||||
|
dropReply=true;
|
||||||
|
await page.getByRole('button',{name:'Gem tid med beskrivelsen ovenfor',exact:true}).click();
|
||||||
|
await page.locator('#uncertain').waitFor({state:'visible'});
|
||||||
|
await page.waitForFunction(()=>!document.querySelector('#check-command').disabled);
|
||||||
|
await page.getByRole('button',{name:'Kontrollér resultat',exact:true}).click();
|
||||||
|
await page.locator('#uncertain').waitFor({state:'hidden'});
|
||||||
|
assert.equal(state.today_minutes,17);
|
||||||
|
assert.equal(writes.filter(x=>x.action==='manual').length,1);
|
||||||
|
disconnected=true;
|
||||||
|
await page.waitForFunction(()=>document.querySelector('[data-action="start"]').disabled===true);
|
||||||
|
const count=writes.length;
|
||||||
|
await page.getByRole('button',{name:'Start timer',exact:true}).evaluate(b=>b.click());
|
||||||
|
assert.equal(writes.length,count);
|
||||||
|
disconnected=false;
|
||||||
|
await page.waitForFunction(()=>document.querySelector('[data-action="start"]').disabled===false);
|
||||||
|
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
|
||||||
|
assert.deepEqual(errors,[]);
|
||||||
|
await page.screenshot({path:`/tmp/bmc-remote-${engine}-${viewport.width}.png`,fullPage:true});
|
||||||
|
await context.close();
|
||||||
|
console.log(`${engine} ${viewport.width}px: login, timer, sagsvalg, manuel tid, tabt svar og forbindelse OK`);
|
||||||
|
}
|
||||||
|
// Normal Hub administration uses separate authenticated routes and clears codes.
|
||||||
|
const context = await browser.newContext({viewport:{width:820,height:1180}});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [], adminWrites = [];
|
||||||
|
const admin = {pin_set:false,remotes:[],devices:[]};
|
||||||
|
let follower = null, seq = 0;
|
||||||
|
await context.route('**/*', async route => {
|
||||||
|
const req=route.request(), path=new URL(req.url()).pathname;
|
||||||
|
if(path==='/remote/manage')return route.fulfill({contentType:'text/html',body:fs.readFileSync('app/modules/remote/manage.html','utf8')});
|
||||||
|
if(path==='/' || path.startsWith('/sag/'))return route.fulfill({contentType:'text/html',body:'<!doctype html><meta charset="utf-8"><title>Test Hub</title><input id="edit"><script src="/static/js/remote-hub.js" defer></script>'});
|
||||||
|
if(path.startsWith('/static/'))return route.fulfill({contentType:path.endsWith('.css')?'text/css':'application/javascript',body:fs.readFileSync('.'+path,'utf8')});
|
||||||
|
let result={};
|
||||||
|
if(path.endsWith('/bootstrap'))result={csrf:'test-csrf',status:'connect'};
|
||||||
|
if(path.endsWith('/admin'))result=admin;
|
||||||
|
if(req.method()!=='GET')adminWrites.push({path,data:req.postDataJSON()});
|
||||||
|
if(path.endsWith('/admin/pin')){admin.pin_set=true;result={ok:true};}
|
||||||
|
if(path.endsWith('/admin/remotes')){admin.remotes=[{id:'test-id',name:'Arbejdsmobil'}];result={id:'test-id',code:'000000000042'};}
|
||||||
|
if(path.endsWith('/rotate'))result={id:'test-id',code:'000000000043'};
|
||||||
|
if(path.endsWith('/follow')){
|
||||||
|
const data=req.postDataJSON();
|
||||||
|
if(data.enable){follower=data.tab_id;result={following:true,sequence:seq};}
|
||||||
|
else if(data.disable){if(follower===data.tab_id)follower=null;result={following:false,sequence:seq};}
|
||||||
|
else result={following:follower===data.tab_id,sequence:seq,selection:follower===data.tab_id&&data.after<seq?{case_id:12}:null};
|
||||||
|
}
|
||||||
|
return route.fulfill({contentType:'application/json',body:JSON.stringify(result)});
|
||||||
|
});
|
||||||
|
page.on('pageerror',e=>errors.push(e.message));
|
||||||
|
await page.goto('http://remote.test/remote/manage');
|
||||||
|
await page.locator('#pin').fill('1234');await page.getByRole('button',{name:'Gem PIN',exact:true}).click();
|
||||||
|
await page.waitForFunction(()=>document.querySelector('#pin').value==='');
|
||||||
|
await page.locator('#name').fill('Arbejdsmobil');await page.getByRole('button',{name:'Opret og vis kode',exact:true}).click();
|
||||||
|
await page.locator('#new-code').waitFor({state:'visible'});
|
||||||
|
assert.equal(await page.locator('#code').textContent(),'000000000042');
|
||||||
|
await page.getByRole('button',{name:'Skjul kode',exact:true}).click();
|
||||||
|
assert.equal(await page.locator('#code').textContent(),'');
|
||||||
|
assert.equal(adminWrites.filter(x=>x.path.endsWith('/admin/pin')).length,1);
|
||||||
|
await page.goto('http://remote.test/');
|
||||||
|
await page.waitForSelector('button', {timeout:5000}).catch(error => { console.error('Hub script errors:',errors); throw error; });
|
||||||
|
assert.equal(await page.locator('button').first().textContent(),'Følg i denne fane', JSON.stringify(errors));
|
||||||
|
await page.getByRole('button',{name:'Følg i denne fane',exact:true}).click();
|
||||||
|
await page.getByRole('button',{name:'Stop med at følge',exact:true}).waitFor();
|
||||||
|
const firstId=follower;
|
||||||
|
const second=await context.newPage();second.on('pageerror',e=>errors.push(e.message));
|
||||||
|
await second.goto('http://remote.test/');
|
||||||
|
assert.equal(follower,firstId);
|
||||||
|
await second.getByRole('button',{name:'Følg i denne fane',exact:true}).click();
|
||||||
|
await second.getByRole('button',{name:'Stop med at følge',exact:true}).waitFor();
|
||||||
|
assert.notEqual(follower,firstId);
|
||||||
|
await page.bringToFront();
|
||||||
|
await page.getByRole('button',{name:'Følg i denne fane',exact:true}).waitFor();
|
||||||
|
await second.bringToFront();
|
||||||
|
seq++;
|
||||||
|
await second.waitForURL('**/sag/12');
|
||||||
|
assert.equal(page.url(),'http://remote.test/');
|
||||||
|
assert.deepEqual(errors,[]);
|
||||||
|
await context.close();
|
||||||
|
console.log(`${engine}: administration, kode, PIN og kun valgt Hub-fane OK`);
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||||
@ -34,7 +34,7 @@ async function fixture(handler) {
|
|||||||
context.window=context;vm.createContext(context);
|
context.window=context;vm.createContext(context);
|
||||||
vm.runInContext(fs.readFileSync('static/js/case-command-composer.js','utf8'),context);
|
vm.runInContext(fs.readFileSync('static/js/case-command-composer.js','utf8'),context);
|
||||||
for(let i=0;i<10;i++)await Promise.resolve();
|
for(let i=0;i<10;i++)await Promise.resolve();
|
||||||
const type=text=>{input.value=text;input.selectionStart=text.length;input.listeners.input();};
|
const type=text=>{input.value=text;input.setSelectionRange(text.length,text.length);input.listeners.input();};
|
||||||
return {controller:context.CaseCommandComposer,input,button,type,calls,nodes:panelNodes,storage};
|
return {controller:context.CaseCommandComposer,input,button,type,calls,nodes:panelNodes,storage};
|
||||||
}
|
}
|
||||||
const response=(status,data)=>({ok:status<400,status,json:async()=>data});
|
const response=(status,data)=>({ok:status<400,status,json:async()=>data});
|
||||||
@ -96,3 +96,63 @@ test('suggestion menu continues from command to minutes to category',async()=>{
|
|||||||
assert.equal(f.input.value,'/tid 30m ');
|
assert.equal(f.input.value,'/tid 30m ');
|
||||||
assert.ok(f.nodes.some(node=>node.tag==='button' && node.children[0]?.textContent==='research'));
|
assert.ok(f.nodes.some(node=>node.tag==='button' && node.children[0]?.textContent==='research'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('required quotes appear automatically after typing the preceding arguments',async()=>{
|
||||||
|
const f=await fixture((url,body)=>response(200,{commands:body.commands}));
|
||||||
|
for(const text of ['/todo ', '/note ', '/tildel ct ', '/tid 30m research ', '/timer start ', '//gruppe Support ']) {
|
||||||
|
f.type(text);
|
||||||
|
assert.equal(f.input.value,text+'""');
|
||||||
|
assert.equal(f.input.selectionStart,text.length+1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('selecting a category inserts description quotes immediately',async()=>{
|
||||||
|
const f=await fixture((url,body)=>response(200,{commands:body.commands}));
|
||||||
|
f.type('/tid 30m re');
|
||||||
|
f.nodes.find(node=>node.tag==='button' && node.children[0]?.textContent==='research').click();
|
||||||
|
assert.equal(f.input.value,'/tid 30m research ""');
|
||||||
|
assert.equal(f.input.selectionStart,f.input.value.length-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Enter inside command quotes keeps the closing quote on the command line',async()=>{
|
||||||
|
const f=await fixture((url,body)=>response(200,{commands:body.commands}));
|
||||||
|
for(const text of ['/todo ""','/note "Tekst"','/tid 30m research "Arbejde"','//todo "Opgave" @ct fredag']) {
|
||||||
|
f.type(text);
|
||||||
|
const caret=text.indexOf('"')+1;
|
||||||
|
f.input.setSelectionRange(caret,caret);
|
||||||
|
let prevented=false;
|
||||||
|
f.input.listeners.keydown({key:'Enter',preventDefault(){prevented=true;}});
|
||||||
|
assert.equal(prevented,true);
|
||||||
|
assert.equal(f.input.value,text+'\n');
|
||||||
|
assert.equal(f.input.selectionStart,text.length+1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('Enter leaves ordinary prose and selections to native editing',async()=>{
|
||||||
|
const f=await fixture((url,body)=>response(200,{commands:body.commands}));
|
||||||
|
f.type('Tekst "citat"');f.input.setSelectionRange(8,8);
|
||||||
|
f.input.listeners.keydown({key:'Enter',preventDefault(){assert.fail('ordinary prose intercepted');}});
|
||||||
|
f.type('/note "tekst"');f.input.setSelectionRange(8,10);
|
||||||
|
f.input.listeners.keydown({key:'Enter',preventDefault(){assert.fail('selection intercepted');}});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Enter chooses the first suggestion without arrow navigation',async()=>{
|
||||||
|
const f=await fixture((url,body)=>response(200,{commands:body.commands}));
|
||||||
|
f.type('/ti');
|
||||||
|
let prevented=false;
|
||||||
|
f.input.listeners.keydown({key:'Enter',preventDefault(){prevented=true;}});
|
||||||
|
assert.equal(prevented,true);
|
||||||
|
assert.equal(f.input.value,'/tid ');
|
||||||
|
assert.equal(f.calls.length,0);
|
||||||
|
});
|
||||||
|
test('Enter honours an explicitly selected suggestion',async()=>{
|
||||||
|
const f=await fixture((url,body)=>response(200,{commands:body.commands}));
|
||||||
|
f.type('/ti');
|
||||||
|
const press=key=>f.input.listeners.keydown({key,preventDefault(){}});
|
||||||
|
press('ArrowDown');press('ArrowDown');press('Enter');
|
||||||
|
assert.equal(f.input.value,'/tildel ');
|
||||||
|
});
|
||||||
|
test('Shift Enter does not accept a suggestion',async()=>{
|
||||||
|
const f=await fixture((url,body)=>response(200,{commands:body.commands}));
|
||||||
|
f.type('/ti');
|
||||||
|
f.input.listeners.keydown({key:'Enter',shiftKey:true,preventDefault(){assert.fail('Shift Enter intercepted');}});
|
||||||
|
assert.equal(f.input.value,'/ti');
|
||||||
|
});
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
const test=require('node:test');const assert=require('node:assert/strict');const {suggest}=require('../../static/js/case-command-suggestions.js');
|
const test=require('node:test');const assert=require('node:assert/strict');const {suggest}=require('../../static/js/case-command-suggestions.js');
|
||||||
const users=[{user_id:3,username:'ct',full_name:'Christian Thomas'}];
|
const users=[{user_id:3,username:'ct',full_name:'Christian Thomas'}];
|
||||||
test('command choice continues with values rather than a full canned example',()=>{const rows=suggest('/ti',3);assert.equal(rows.find(r=>r.label==='/tid').value,'/tid ');assert.ok(suggest('/tid ',5).some(r=>r.value==='30m '));});
|
test('command choice continues with values rather than a full canned example',()=>{const rows=suggest('/ti',3);assert.equal(rows.find(r=>r.label==='/tid').value,'/tid ');assert.ok(suggest('/tid ',5).some(r=>r.value==='30m '));});
|
||||||
test('completion follows time category and description',()=>{assert.ok(suggest('/tid 30m re',11).some(r=>r.value==='research '));assert.ok(suggest('/tid 30m research ',18).some(r=>r.value==='"Beskrivelse" '));});
|
test('completion follows time category and description',()=>{assert.ok(suggest('/tid 30m re',11).some(r=>r.value==='research '));assert.ok(suggest('/tid 30m research ',18).some(r=>r.value==='"" ' && r.caret===1));});
|
||||||
test('employee completion filters initials and uses an unambiguous identifier',()=>{const row=suggest('/tildel ct',10,{users})[0];assert.equal(row.label,'Christian Thomas');assert.equal(row.value,'3 ');});
|
test('employee completion filters initials and uses an unambiguous identifier',()=>{const row=suggest('/tildel ct',10,{users})[0];assert.equal(row.label,'Christian Thomas');assert.equal(row.value,'ct ');});
|
||||||
test('replacement preserves existing explanation and handles mid-command caret',()=>{let line='/tildel ct "Bestil varer"';const row=suggest(line,10,{users})[0];assert.equal(line.slice(0,row.from)+row.value+line.slice(row.to),'/tildel 3 "Bestil varer"');const command=suggest('/tildel',3)[0];assert.equal(command.to,7);});
|
test('replacement preserves existing explanation and handles mid-command caret',()=>{let line='/tildel ct "Bestil varer"';const row=suggest(line,10,{users})[0];assert.equal(line.slice(0,row.from)+row.value+line.slice(row.to),'/tildel ct "Bestil varer"');const command=suggest('/tildel',3)[0];assert.equal(command.to,7);});
|
||||||
test('subcase groups, dates and todo employee choices',()=>{assert.equal(suggest('//gruppe Sup',12,{groups:[{name:'Support'}]})[0].value,'"Support" ');assert.equal(suggest('/udsat i m',10)[0].value,'i morgen');assert.equal(suggest('/todo "Bestil" @ct',18,{users})[0].value,'@3 ');});
|
test('subcase groups, dates and todo employee choices',()=>{assert.equal(suggest('//gruppe Sup',12,{groups:[{name:'Support'}]})[0].value,'"Support" ');assert.equal(suggest('/udsat i m',10)[0].value,'i morgen');assert.equal(suggest('/todo "Bestil" @ct',18,{users})[0].value,'@ct ');});
|
||||||
|
|
||||||
|
test('text commands insert quotes with the caret between them, including child scope',()=>{for(const prefix of ['/', '//'])for(const name of ['todo','undersag','note','mail','løsning']){const row=suggest(prefix+name,(prefix+name).length).find(r=>r.label===prefix+name);assert.equal(row.value,prefix+name+' ""');assert.equal(row.value[row.caret-1],'"');assert.equal(row.value[row.caret],'"');}});
|
||||||
|
|
||||||
|
test('child assignment inserts initials, never the numeric user ID',()=>{const row=suggest('//tildel ct',11,{users})[0];assert.equal(row.value,'ct ');});
|
||||||
|
test('duplicate initials use a unique username',()=>{const people=[...users,{user_id:4,username:'carlt',full_name:'Carl Thomsen'}];const row=suggest('/tildel Carl',12,{users:people})[0];assert.equal(row.value,'carlt ');});
|
||||||
|
|||||||
55
tests/js/case-create-commands.test.cjs
Normal file
55
tests/js/case-create-commands.test.cjs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
const test=require('node:test'),assert=require('node:assert/strict');
|
||||||
|
const {CommandRun,init}=require('../../static/js/case-create-commands.js');
|
||||||
|
const parser=require('../../static/js/case-commands.js');
|
||||||
|
const completion=require('../../static/js/case-command-suggestions.js');
|
||||||
|
test('creation commands target the new case and retry with the same UUID and payload',async()=>{
|
||||||
|
const calls=[];let fail=true;
|
||||||
|
const run=new CommandRun(async(url,body)=>{calls.push({url,body:JSON.stringify(body)});if(fail){fail=false;throw new Error('offline');}return {};},()=> 'same-uuid');
|
||||||
|
const parsed=parser.parse('Sagens beskrivelse\n/undersag "Router"\n//tildel ct "Opsæt router"\n//tid 30m support "Opsætning"');
|
||||||
|
assert.equal(parsed.comment,'Sagens beskrivelse');assert.equal(parsed.errors.length,0);
|
||||||
|
run.attach(198,parsed.commands);
|
||||||
|
await assert.rejects(run.execute(),/offline/);assert.equal(run.pending.caseId,198);
|
||||||
|
await run.execute();assert.equal(run.pending,null);assert.deepEqual(calls[0],calls[1]);
|
||||||
|
assert.equal(calls[0].url,'/api/v1/sag/198/command-execute');
|
||||||
|
});
|
||||||
|
class Element {
|
||||||
|
constructor(){this.children=[];this.listeners={};this.value='';this.selectionStart=0;this.selectionEnd=0;this.classList={toggle(){}};}
|
||||||
|
append(...nodes){this.children.push(...nodes);}after(){}replaceChildren(){this.children=[];}setAttribute(){}
|
||||||
|
addEventListener(type,fn){this.listeners[type]=fn;}focus(){}
|
||||||
|
setSelectionRange(start,end){this.selectionStart=start;this.selectionEnd=end;}
|
||||||
|
setRangeText(text,start,end){this.value=this.value.slice(0,start)+text+this.value.slice(end);this.setSelectionRange(start+text.length,start+text.length);}
|
||||||
|
dispatchEvent(event){this.listeners[event.type]?.(event);}
|
||||||
|
}
|
||||||
|
async function fixture(handler){
|
||||||
|
const nodes=[],input=new Element(),button=new Element();
|
||||||
|
const root={document:{getElementById:id=>id==='beskrivelse'?input:id==='submitBtn'?button:{textContent:'{"users":[{"user_id":3,"username":"ct","full_name":"Christian Thomas"}],"groups":[]}'},createElement:()=>{const node=new Element();nodes.push(node);return node;},createTextNode:text=>({textContent:text})},CaseCommands:parser,CaseCommandSuggestions:completion,Event:class{constructor(type){this.type=type;}},crypto:{randomUUID:(()=>{let n=0;return ()=>`uuid-${++n}`;})()},sessionStorage:{getItem(){return null;},setItem(){},removeItem(){}},fetch:async(url,options)=>options&&handler?handler(url,JSON.parse(options.body)):({ok:true,json:async()=>({id:17})})};
|
||||||
|
init(root);await root.caseCreateCommands.ready;
|
||||||
|
const type=text=>{input.value=text;input.setSelectionRange(text.length,text.length);input.dispatchEvent(new root.Event('input'));};
|
||||||
|
const press=key=>input.listeners.keydown({key,preventDefault(){}});
|
||||||
|
return {root,input,nodes,type,press};
|
||||||
|
}
|
||||||
|
test('new case supports first-suggestion Enter, initials and automatic description quotes',async()=>{
|
||||||
|
const f=await fixture();f.type('Beskrivelse\n/ti');f.press('Enter');assert.equal(f.input.value,'Beskrivelse\n/tid ');
|
||||||
|
f.type('Beskrivelse\n/tid 30m support ');assert.equal(f.input.value,'Beskrivelse\n/tid 30m support ""');
|
||||||
|
f.type('Beskrivelse\n/undersag "Router"\n//tildel ct');f.press('Enter');
|
||||||
|
assert.equal(f.input.value,'Beskrivelse\n/undersag "Router"\n//tildel ct ""');
|
||||||
|
});
|
||||||
|
test('Enter preserves closing quotes and prose stays separate from commands',async()=>{
|
||||||
|
const f=await fixture();f.type('Beskrivelse\n/todo "Opgave"');f.input.setSelectionRange(f.input.value.length-1,f.input.value.length-1);f.press('Enter');
|
||||||
|
assert.equal(f.input.value,'Beskrivelse\n/todo "Opgave"\n');
|
||||||
|
const parsed=f.root.caseCreateCommands.prepare();assert.equal(parsed.comment,'Beskrivelse');assert.equal(parsed.commands[0].title,'Opgave');
|
||||||
|
f.type('Beskrivelse\n/ukendt');assert.throws(()=>f.root.caseCreateCommands.prepare(),/Ukendt kommando/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejected command can be corrected on the same created case',async()=>{
|
||||||
|
const calls=[];let reject=true;
|
||||||
|
const f=await fixture(async(url,body)=>{calls.push({url,body});return reject?{ok:false,status:400,json:async()=>({detail:'Initialerne er tvetydige'})}:{ok:true,json:async()=>({results:[]})};});
|
||||||
|
f.type('Beskrivelse\n/tildel ct "Overdrag"');f.root.caseCreateCommands.prepare();
|
||||||
|
await assert.rejects(f.root.caseCreateCommands.created({id:99}),/Sag #99 er oprettet/);
|
||||||
|
assert.equal(f.input.readOnly,false);assert.equal(f.root.caseCreateCommands.pendingCaseId(),99);
|
||||||
|
reject=false;f.type('Beskrivelse\n/tildel carlt "Overdrag"');await f.root.caseCreateCommands.retry();
|
||||||
|
assert.equal(calls[1].url,'/api/v1/sag/99/command-execute');
|
||||||
|
assert.equal(calls[1].body.commands[0].assignee,'carlt');
|
||||||
|
assert.notEqual(calls[0].body.request_id,calls[1].body.request_id);
|
||||||
|
assert.equal(f.root.caseCreateCommands.hasPending(),false);
|
||||||
|
});
|
||||||
27
tests/js/case-v5-assignment.test.cjs
Normal file
27
tests/js/case-v5-assignment.test.cjs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
const test=require('node:test');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const fs=require('node:fs');
|
||||||
|
const vm=require('node:vm');
|
||||||
|
const source=fs.readFileSync('static/js/case-v5.js','utf8');
|
||||||
|
function fixture(groupId){
|
||||||
|
const nodes={v5UserSearch:{value:''},v5UserOptions:{children:[],replaceChildren(){this.children=[];},append(button){this.children.push(button);}}};
|
||||||
|
const context={members:[{user_id:7,display_name:'Christian Thomas',group_ids:[2,3]},{user_id:8,display_name:'Mette Jensen',group_ids:[4]}],groupId,userId:null,busy:false,pending:null,byId:id=>nodes[id],renderGroups(){},renderActions(){},document:{createElement(){return {setAttribute(key,value){this[key]=value;},addEventListener(type,fn){this[type]=fn;}};}}};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(source.slice(source.indexOf(' function employeeInitials('),source.indexOf(' async function loadMembers()')),context);
|
||||||
|
context.renderUsers();
|
||||||
|
return {context,nodes};
|
||||||
|
}
|
||||||
|
test('initial buttons identify employees and infer their assignment group',()=>{
|
||||||
|
const {context,nodes}=fixture(2);
|
||||||
|
const buttons=nodes.v5UserOptions.children;
|
||||||
|
assert.deepEqual(buttons.map(b=>b.textContent),['CT','MJ']);
|
||||||
|
assert.equal(buttons[0].title,'Christian Thomas');
|
||||||
|
buttons[0].click();assert.equal(context.userId,7);assert.equal(context.groupId,2);
|
||||||
|
nodes.v5UserOptions.children[1].click();assert.equal(context.userId,8);assert.equal(context.groupId,4);
|
||||||
|
});
|
||||||
|
test('initials can be searched and an unrelated group is replaced',()=>{
|
||||||
|
const {context,nodes}=fixture(99);
|
||||||
|
nodes.v5UserSearch.value='ct';context.renderUsers();
|
||||||
|
assert.equal(nodes.v5UserOptions.children.length,1);
|
||||||
|
nodes.v5UserOptions.children[0].click();assert.equal(context.groupId,2);
|
||||||
|
});
|
||||||
25
tests/js/remote-recovery.test.cjs
Normal file
25
tests/js/remote-recovery.test.cjs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
|
||||||
|
function client(fetch){
|
||||||
|
const context={fetch,AbortController,setTimeout,clearTimeout,crypto:require('node:crypto').webcrypto,Uint8Array};
|
||||||
|
vm.createContext(context);vm.runInContext(fs.readFileSync('static/js/remote-api.js','utf8').replaceAll('export ',''),context);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
const response=(data,type='application/json',extra={})=>({ok:true,headers:{get:key=>key==='Content-Type'?type:null},json:async()=>data,...extra});
|
||||||
|
test('HTML or redirected login responses cannot replace the remote snapshot',async()=>{
|
||||||
|
for(const result of [response({},'text/html'),response({},'application/json',{redirected:true})]){
|
||||||
|
const c=client(async()=>result);await assert.rejects(c.api('/state'),/forventede svar/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('network failure is recoverable and later state can be read',async()=>{
|
||||||
|
let offline=true;
|
||||||
|
const c=client(async(url,options)=>{assert.ok(options.signal);if(offline)throw new Error('offline');return response({timer:{active:{active:false}}});});
|
||||||
|
await assert.rejects(c.api('/state'),/Prøver igen/);offline=false;assert.equal((await c.api('/state')).timer.active.active,false);
|
||||||
|
});
|
||||||
|
test('unchanged list data preserves its DOM, changed data triggers a render',()=>{
|
||||||
|
const source=fs.readFileSync('static/js/remote-mobile.js','utf8');
|
||||||
|
const start=source.indexOf('const renderedLists =');const end=source.indexOf('try { pendingCall',start);
|
||||||
|
const c={Map};vm.createContext(c);vm.runInContext(source.slice(start,end),c);
|
||||||
|
assert.equal(c.listChanged('messages',[{id:1,text:'Hej'}]),true);
|
||||||
|
assert.equal(c.listChanged('messages',[{id:1,text:'Hej'}]),false);
|
||||||
|
assert.equal(c.listChanged('messages',[{id:1,text:'Ændret'}]),true);
|
||||||
|
});
|
||||||
50
tests/test_hardware_labels.py
Normal file
50
tests/test_hardware_labels.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import asyncio
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.modules.hardware.labels import hardware_label_url, hardware_label_qr
|
||||||
|
from app.modules.hardware.frontend import views
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_links_to_exact_asset():
|
||||||
|
assert hardware_label_url('https://hub.example.dk/', 42) == 'https://hub.example.dk/hardware/42'
|
||||||
|
assert hardware_label_url('http://192.168.1.10:8001', 42) == 'http://192.168.1.10:8001/hardware/42'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('url', ['javascript:alert(1)', 'https://user:secret@hub.dk', 'https://hub.dk?token=secret', 'https://hub.dk/#secret', ''])
|
||||||
|
def test_invalid_label_url_is_rejected(url):
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
hardware_label_url(url, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_is_a_local_vector_with_quiet_zone():
|
||||||
|
xml = ET.fromstring(hardware_label_qr('https://hub.example.dk/hardware/42'))
|
||||||
|
assert xml.tag == '{http://www.w3.org/2000/svg}svg'
|
||||||
|
assert xml.find('{http://www.w3.org/2000/svg}path') is not None
|
||||||
|
assert 'http' not in xml.find('{http://www.w3.org/2000/svg}path').attrib['d']
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_route_renders_asset_and_escapes_text(monkeypatch):
|
||||||
|
monkeypatch.setattr(views, 'execute_query', lambda sql, params: [{'id':42, 'brand':'BMC', 'model':'<script>bad</script>', 'serial_number':'ABC-123'}])
|
||||||
|
request = Request({'type':'http','method':'GET','path':'/hardware/42/label','headers':[],'scheme':'http','server':('localhost',8001),'query_string':b''})
|
||||||
|
response = asyncio.run(views.hardware_label(request,42,size='62x40',base_url='https://hub.example.dk'))
|
||||||
|
body=response.body.decode()
|
||||||
|
assert 'BMC-00042' in body
|
||||||
|
assert 'https://hub.example.dk/hardware/42' in body
|
||||||
|
assert '<script>bad</script>' not in body
|
||||||
|
assert '<script>bad</script>' in body
|
||||||
|
assert 'size:62mm 40mm' in body
|
||||||
|
assert response.headers['cache-control']=='no-store'
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_asset_and_invalid_size(monkeypatch):
|
||||||
|
monkeypatch.setattr(views,'execute_query',lambda *args: [])
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(views.hardware_label(None,42,size='62x40',base_url='https://hub.example.dk'))
|
||||||
|
assert error.value.status_code==404
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(views.hardware_label(None,42,size='bad',base_url='https://hub.example.dk'))
|
||||||
|
assert error.value.status_code==400
|
||||||
469
tests/test_remote_control.py
Normal file
469
tests/test_remote_control.py
Normal file
@ -0,0 +1,469 @@
|
|||||||
|
"""Security and real PostgreSQL remote tests in a disposable isolated schema.
|
||||||
|
|
||||||
|
Run integration cases with BMC_REMOTE_DB_TESTS=1; only localhost is accepted.
|
||||||
|
No production schema, customer record or existing time entry is modified.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from psycopg2.pool import ThreadedConnectionPool
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core import database
|
||||||
|
from app.core.auth_dependencies import get_current_user
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
from app.modules.remote import security, service
|
||||||
|
from app.modules.remote.router import router, views, Command
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_preserves_leading_zeroes(monkeypatch):
|
||||||
|
monkeypatch.setattr(security.secrets, 'randbelow', lambda maximum: 42)
|
||||||
|
assert security.new_code() == '000000000042'
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_is_copenhagen_calendar_day():
|
||||||
|
assert str(security.today(datetime(2026, 10, 13, 22, 30, tzinfo=timezone.utc))) == '2026-10-14'
|
||||||
|
assert str(security.today(datetime(2026, 1, 13, 22, 30, tzinfo=timezone.utc))) == '2026-01-13'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('seconds,status', [(3601, 'normal'), (3600, 'warning'), (0, 'warning'), (-1, 'overdue')])
|
||||||
|
def test_deadline_thresholds(seconds, status):
|
||||||
|
now = datetime(2026, 10, 13, 12, tzinfo=timezone.utc)
|
||||||
|
row = service.case_display({'deadline': now + timedelta(seconds=seconds)}, now)
|
||||||
|
assert row['deadline_state'] == status
|
||||||
|
|
||||||
|
|
||||||
|
def test_naive_deadline_uses_danish_timezone():
|
||||||
|
row = service.case_display({'deadline': datetime(2026, 10, 13, 14)}, datetime(2026, 10, 13, 12, tzinfo=timezone.utc))
|
||||||
|
assert row['deadline_seconds'] == 0
|
||||||
|
assert service.case_display({'deadline': None})['deadline_state'] == 'none'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("scheme", ["http", "https"])
|
||||||
|
def test_flag_origin_and_csrf(monkeypatch, scheme):
|
||||||
|
app = FastAPI(); app.include_router(router)
|
||||||
|
client = TestClient(app, base_url=f'{scheme}://hub.test')
|
||||||
|
monkeypatch.setattr(settings, 'REMOTE_ENABLED', False)
|
||||||
|
assert client.get('/api/v1/remote/bootstrap').status_code == 404
|
||||||
|
monkeypatch.setattr(settings, 'REMOTE_ENABLED', True)
|
||||||
|
monkeypatch.setattr(settings, 'REMOTE_LOOKUP_KEY', '')
|
||||||
|
assert client.get('/api/v1/remote/bootstrap').status_code == 503
|
||||||
|
monkeypatch.setattr(settings, 'REMOTE_LOOKUP_KEY', 'test-key-only-' * 4)
|
||||||
|
response = client.get('/api/v1/remote/bootstrap')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert ('Secure' in response.headers['set-cookie']) == (scheme == 'https')
|
||||||
|
assert 'HttpOnly' in response.headers['set-cookie']
|
||||||
|
assert client.post('/api/v1/remote/connect', json={'code': '0'*12, 'pin': '1234'}).status_code == 403
|
||||||
|
assert client.post('/api/v1/remote/connect', headers={'Origin': 'https://other.test'}, json={'pin': '1234'}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_token_is_not_hub_jwt():
|
||||||
|
assert AuthService.verify_token('remote-device-random-token') is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db(monkeypatch):
|
||||||
|
import os
|
||||||
|
if os.environ.get('BMC_REMOTE_DB_TESTS') != '1':
|
||||||
|
pytest.skip('Set BMC_REMOTE_DB_TESTS=1 for isolated local PostgreSQL integration tests')
|
||||||
|
assert urlsplit(settings.DATABASE_URL).hostname in {'localhost', '127.0.0.1', '::1'}
|
||||||
|
schema = 'remote_test_' + uuid4().hex
|
||||||
|
root = psycopg2.connect(settings.DATABASE_URL)
|
||||||
|
root.autocommit = True
|
||||||
|
with root.cursor() as cur:
|
||||||
|
cur.execute('CREATE SCHEMA ' + schema)
|
||||||
|
pool = ThreadedConnectionPool(1, 12, dsn=settings.DATABASE_URL, options='-c search_path=' + schema)
|
||||||
|
monkeypatch.setattr(database, 'connection_pool', pool)
|
||||||
|
monkeypatch.setattr(settings, 'REMOTE_ENABLED', True)
|
||||||
|
monkeypatch.setattr(settings, 'REMOTE_LOOKUP_KEY', 'isolated-test-key-only-' * 3)
|
||||||
|
monkeypatch.setattr(settings, 'WORKFLOWS_ENABLED', False)
|
||||||
|
permissions = {1: ['cases.view'], 2: ['cases.view']}
|
||||||
|
monkeypatch.setattr(AuthService, 'get_user_permissions', lambda uid: permissions.get(uid, []))
|
||||||
|
monkeypatch.setattr(service, 'get_user_messages_summary', lambda uid: {'count': 0, 'list': []})
|
||||||
|
try:
|
||||||
|
with database.atomic_transaction():
|
||||||
|
database.execute_query('''CREATE TABLE users(user_id INTEGER PRIMARY KEY,username TEXT,full_name TEXT,email TEXT,is_2fa_enabled BOOLEAN DEFAULT FALSE,is_superadmin BOOLEAN DEFAULT FALSE,is_active BOOLEAN DEFAULT TRUE);
|
||||||
|
CREATE TABLE sessions(user_id INTEGER,token_jti TEXT,expires_at TIMESTAMP,revoked BOOLEAN DEFAULT FALSE);
|
||||||
|
CREATE TABLE customers(id INTEGER PRIMARY KEY,name TEXT);
|
||||||
|
CREATE TABLE sag_sager(id INTEGER PRIMARY KEY,titel TEXT,status TEXT,deadline TIMESTAMP,customer_id INTEGER,ansvarlig_bruger_id INTEGER,deleted_at TIMESTAMP,created_at TIMESTAMP DEFAULT NOW());
|
||||||
|
CREATE TABLE contacts(id INTEGER PRIMARY KEY,first_name TEXT,last_name TEXT,email TEXT,phone TEXT,mobile TEXT,title TEXT,department TEXT,user_company TEXT,is_active BOOLEAN,created_at TIMESTAMP,updated_at TIMESTAMP);
|
||||||
|
CREATE TABLE contact_companies(contact_id INTEGER,customer_id INTEGER);
|
||||||
|
INSERT INTO contacts(id,first_name,last_name,phone,mobile,is_active) VALUES(1,'Test','Kontakt','+45 12345678','87654321',TRUE),(2,'Inaktiv','Kontakt','12345678',NULL,FALSE);
|
||||||
|
CREATE TABLE settings(key TEXT,value TEXT);
|
||||||
|
CREATE TABLE tmodule_times (LIKE public.tmodule_times INCLUDING DEFAULTS);
|
||||||
|
ALTER TABLE tmodule_times ALTER COLUMN id DROP DEFAULT;
|
||||||
|
ALTER TABLE tmodule_times ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY;
|
||||||
|
CREATE FUNCTION remote_test_update() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN NEW.updated_at=clock_timestamp();RETURN NEW;END$$;
|
||||||
|
CREATE TRIGGER remote_test_update BEFORE UPDATE ON tmodule_times FOR EACH ROW EXECUTE FUNCTION remote_test_update();
|
||||||
|
INSERT INTO users(user_id,username,full_name) VALUES(1,'test1','Test One'),(2,'test2','Test Two');
|
||||||
|
INSERT INTO sag_sager(id,titel,status,ansvarlig_bruger_id) VALUES(11,'Ny testsag','Ny',1),(12,'Anden sag','I gang',1),(13,'Lukket sag','Lukket',1),(14,'Andens sag','Ny',2);''')
|
||||||
|
database.execute_query(Path('migrations/1069_remote_control.sql').read_text())
|
||||||
|
yield permissions
|
||||||
|
finally:
|
||||||
|
pool.closeall()
|
||||||
|
with root.cursor() as cur:
|
||||||
|
cur.execute('DROP SCHEMA ' + schema + ' CASCADE')
|
||||||
|
root.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=["http", "https"])
|
||||||
|
def web(db, request):
|
||||||
|
app = FastAPI(); app.include_router(router); app.include_router(views)
|
||||||
|
async def hub_user(request: Request):
|
||||||
|
uid = request.cookies.get('access_token')
|
||||||
|
if uid not in {'hub1', 'hub2'}:
|
||||||
|
raise HTTPException(401, 'Hub login required')
|
||||||
|
uid = int(uid[-1])
|
||||||
|
return {'id': uid, 'username': 'test' + str(uid), 'full_name': 'Test', 'permissions': db[uid]}
|
||||||
|
app.dependency_overrides[get_current_user] = hub_user
|
||||||
|
@app.get('/ordinary')
|
||||||
|
async def ordinary(user=__import__('fastapi').Depends(get_current_user)):
|
||||||
|
return {'id': user['id']}
|
||||||
|
client = TestClient(app, base_url=f'{request.param}://hub.test')
|
||||||
|
client.cookies.set('access_token', 'hub1')
|
||||||
|
csrf = client.get('/api/v1/remote/bootstrap').json()['csrf']
|
||||||
|
headers = {'Origin': str(client.base_url).rstrip('/'), 'X-Remote-CSRF': csrf}
|
||||||
|
return client, headers
|
||||||
|
|
||||||
|
|
||||||
|
def connect(web):
|
||||||
|
client, headers = web
|
||||||
|
assert client.put('/api/v1/remote/admin/pin', json={'pin': '1234'}, headers=headers).status_code == 200
|
||||||
|
remote = client.post('/api/v1/remote/admin/remotes', json={'name': 'Test remote'}, headers=headers).json()
|
||||||
|
response = client.post('/api/v1/remote/connect', json={'code': remote['code'], 'pin': '1234'}, headers=headers)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert ('Secure' in response.headers['set-cookie']) == (client.base_url.scheme == 'https')
|
||||||
|
assert 'HttpOnly' in response.headers['set-cookie']
|
||||||
|
return remote
|
||||||
|
|
||||||
|
|
||||||
|
def post_command(web, action, **kwargs):
|
||||||
|
client, headers = web
|
||||||
|
snapshot = client.get('/api/v1/remote/state').json()
|
||||||
|
payload = {'request_id': str(uuid4()), 'version': snapshot['version'], 'timer_version': snapshot['timer_version'], 'action': action, **kwargs}
|
||||||
|
return client.post('/api/v1/remote/command', json=payload, headers=headers), payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_isolation_pin_rotation_expiry(web, db):
|
||||||
|
client, headers = web; remote = connect(web)
|
||||||
|
assert client.get('/api/v1/remote/state').status_code == 200
|
||||||
|
# A remote cookie alone grants no full Hub or management access.
|
||||||
|
client.cookies.delete('access_token')
|
||||||
|
assert client.get('/ordinary').status_code == 401
|
||||||
|
assert client.get('/api/v1/remote/admin').status_code == 401
|
||||||
|
client.cookies.set('access_token', 'hub2')
|
||||||
|
assert client.post(f"/api/v1/remote/admin/remotes/{remote['id']}/rotate", json={}, headers=headers).status_code == 404
|
||||||
|
assert client.get('/api/v1/remote/admin').json()['remotes'] == []
|
||||||
|
client.cookies.set('access_token', 'hub1')
|
||||||
|
assert client.put('/api/v1/remote/admin/pin', json={'pin': '5678'}, headers=headers).status_code == 200
|
||||||
|
assert client.get('/api/v1/remote/state').status_code == 423
|
||||||
|
database.execute_query("UPDATE remote_attempts SET next_allowed_at='epoch'")
|
||||||
|
assert client.post('/api/v1/remote/unlock', json={'pin': '5678'}, headers=headers).status_code == 200
|
||||||
|
database.execute_query("UPDATE remote_devices SET unlocked_date=unlocked_date-1")
|
||||||
|
assert client.get('/api/v1/remote/state').status_code == 423
|
||||||
|
database.execute_query("UPDATE remote_devices SET expires_at=NOW()-INTERVAL '1 second'")
|
||||||
|
assert client.get('/api/v1/remote/state').status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_attempts_are_committed_and_shared_between_connections(web):
|
||||||
|
client, headers = web
|
||||||
|
assert client.post('/api/v1/remote/connect', json={'code': '000000000042', 'pin': '0000'}, headers=headers).status_code == 401
|
||||||
|
second = TestClient(client.app, base_url=str(client.base_url)); second.cookies.update(client.cookies)
|
||||||
|
result = second.post('/api/v1/remote/connect', json={'code': '000000000042', 'pin': '0000'}, headers=headers)
|
||||||
|
assert result.status_code == 429 and 1 <= int(result.headers['Retry-After']) <= 30
|
||||||
|
assert database.execute_query_single('SELECT COUNT(*) AS count FROM remote_attempts')['count'] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_is_atomic_across_concurrent_db_connections(db):
|
||||||
|
def attempt():
|
||||||
|
try:
|
||||||
|
security.reserve_attempt('isolated-ip', 1, uuid4())
|
||||||
|
return 200
|
||||||
|
except HTTPException as exc:
|
||||||
|
return exc.status_code
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||||
|
outcomes = list(executor.map(lambda _: attempt(), range(4)))
|
||||||
|
assert sorted(outcomes) == [200, 429, 429, 429]
|
||||||
|
|
||||||
|
|
||||||
|
def process_attempt(options):
|
||||||
|
"""Separate spawned worker: no shared Python lock, pool, or rate-limit memory."""
|
||||||
|
settings.REMOTE_LOOKUP_KEY = 'isolated-test-key-only-' * 3
|
||||||
|
database.connection_pool = ThreadedConnectionPool(1, 2, dsn=settings.DATABASE_URL, options=options)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
security.reserve_attempt('cross-process-ip', 1)
|
||||||
|
return 200
|
||||||
|
except HTTPException as exc:
|
||||||
|
return exc.status_code
|
||||||
|
finally:
|
||||||
|
database.connection_pool.closeall()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_is_shared_between_processes(db):
|
||||||
|
import multiprocessing
|
||||||
|
conn = database.get_db_connection()
|
||||||
|
options = conn.get_dsn_parameters()['options']
|
||||||
|
database.release_db_connection(conn)
|
||||||
|
with ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as executor:
|
||||||
|
assert sorted(executor.map(process_attempt, [options, options])) == [200, 429]
|
||||||
|
|
||||||
|
|
||||||
|
def test_timer_actions_manual_time_idempotency_and_conflict(web):
|
||||||
|
client, headers = web; connect(web)
|
||||||
|
result, payload = post_command(web, 'start', case_id=11)
|
||||||
|
assert result.status_code == 200, result.text
|
||||||
|
assert client.post('/api/v1/remote/command', json=payload, headers=headers).json() == result.json()
|
||||||
|
assert client.get('/api/v1/remote/command-result/' + payload['request_id']).json()['completed'] is True
|
||||||
|
assert database.execute_query_single('SELECT COUNT(*) AS count FROM tmodule_times')['count'] == 1
|
||||||
|
payload['case_id'] = 12
|
||||||
|
assert client.post('/api/v1/remote/command', json=payload, headers=headers).status_code == 409
|
||||||
|
assert post_command(web, 'select', case_id=12)[0].status_code == 409
|
||||||
|
assert post_command(web, 'select', case_id=12, switch_timer=False)[0].status_code == 200
|
||||||
|
assert client.get('/api/v1/remote/state').json()['timer']['active']['sag_id'] == 11
|
||||||
|
assert post_command(web, 'select', case_id=12, switch_timer=True)[0].status_code == 200
|
||||||
|
snapshot = client.get('/api/v1/remote/state').json()
|
||||||
|
assert snapshot['timer']['active']['sag_id'] == 12
|
||||||
|
assert post_command(web, 'pause')[0].status_code == 200
|
||||||
|
tid = snapshot['timer']['active']['time_entry_id']
|
||||||
|
assert post_command(web, 'resume', time_id=tid)[0].status_code == 200
|
||||||
|
assert post_command(web, 'stop', time_id=tid, description='Test arbejde')[0].status_code == 200
|
||||||
|
assert post_command(web, 'manual', case_id=12, description='Test manuel tid', minutes=17)[0].status_code == 200
|
||||||
|
assert client.get('/api/v1/remote/state').json()['today_minutes'] >= 17
|
||||||
|
# Desktop modifications invalidate an already prepared remote command.
|
||||||
|
snapshot = client.get('/api/v1/remote/state').json()
|
||||||
|
payload = dict(request_id=str(uuid4()), version=snapshot['version'], timer_version=snapshot['timer_version'], action='start', case_id=11)
|
||||||
|
import importlib
|
||||||
|
timers = importlib.import_module('app.timetracking.backend.router')
|
||||||
|
asyncio.run(timers.start_live_timer_v1({'sag_id': 12, 'billing_mode': 'automatic'}, {'id': 1, 'username': 'test1'}))
|
||||||
|
assert client.post('/api/v1/remote/command', json=payload, headers=headers).status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_commands_cannot_create_duplicate_timers(web):
|
||||||
|
client, headers = web; connect(web)
|
||||||
|
snapshot = client.get('/api/v1/remote/state').json()
|
||||||
|
def start(_):
|
||||||
|
payload = Command(request_id=uuid4(), version=snapshot['version'], timer_version=snapshot['timer_version'], action='start', case_id=11)
|
||||||
|
try:
|
||||||
|
asyncio.run(service.command(payload, {'id': 1, 'username': 'test1', 'permissions': ['cases.view']}))
|
||||||
|
return 200
|
||||||
|
except HTTPException as exc:
|
||||||
|
return exc.status_code
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
assert sorted(executor.map(start, range(2))) == [200, 409]
|
||||||
|
assert database.execute_query_single('SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer')['count'] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_selected_follower_and_no_stale_selection(web):
|
||||||
|
client, headers = web; connect(web)
|
||||||
|
first, second = str(uuid4()), str(uuid4())
|
||||||
|
client.post('/api/v1/remote/follow', json={'tab_id': first, 'enable': True}, headers=headers)
|
||||||
|
assert post_command(web, 'select', case_id=11)[0].status_code == 200
|
||||||
|
event = client.post('/api/v1/remote/follow', json={'tab_id': first}, headers=headers).json()
|
||||||
|
assert event['selection'] == {'case_id': 11}
|
||||||
|
client.post('/api/v1/remote/follow', json={'tab_id': second, 'enable': True}, headers=headers)
|
||||||
|
assert client.post('/api/v1/remote/follow', json={'tab_id': first}, headers=headers).json()['following'] is False
|
||||||
|
assert post_command(web, 'select', case_id=12)[0].status_code == 200
|
||||||
|
database.execute_query("UPDATE remote_user_state SET follower_seen_at=NOW()-INTERVAL '15 seconds'")
|
||||||
|
event = client.post('/api/v1/remote/follow', json={'tab_id': second}, headers=headers).json()
|
||||||
|
assert event['selection'] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_filters_permissions_and_revocation(web, db):
|
||||||
|
client, headers = web; remote = connect(web)
|
||||||
|
assert {c['id'] for c in client.get('/api/v1/remote/cases?kind=new').json()['items']} == {11,14}
|
||||||
|
assert {c['id'] for c in client.get('/api/v1/remote/cases?kind=own').json()['items']} == {11,12}
|
||||||
|
assert client.get('/api/v1/remote/cases?q=11').json()['items'][0]['id'] == 11
|
||||||
|
db[1] = []
|
||||||
|
assert client.get('/api/v1/remote/cases').status_code == 403
|
||||||
|
assert post_command(web, 'start', case_id=11)[0].status_code == 403
|
||||||
|
db[1] = ['cases.view']
|
||||||
|
assert client.request('DELETE', f"/api/v1/remote/admin/remotes/{remote['id']}", json={}, headers=headers).status_code == 200
|
||||||
|
assert client.get('/api/v1/remote/state').status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_leading_zero_code_and_no_hashes_in_administration(web, monkeypatch):
|
||||||
|
client, headers = web
|
||||||
|
monkeypatch.setattr('app.modules.remote.router.new_code', lambda: '000000000042')
|
||||||
|
remote = connect(web)
|
||||||
|
assert remote['code'] == '000000000042'
|
||||||
|
admin = client.get('/api/v1/remote/admin').json()
|
||||||
|
assert not {'code', 'code_hash', 'code_lookup', 'pin_hash', 'token_hash'}.intersection(admin['remotes'][0])
|
||||||
|
assert not {'token_hash', 'pin_hash'}.intersection(admin['devices'][0])
|
||||||
|
device_id = admin['devices'][0]['id']
|
||||||
|
assert client.request('DELETE', '/api/v1/remote/admin/devices/' + device_id, json={}, headers=headers).status_code == 200
|
||||||
|
assert client.get('/api/v1/remote/state').status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotate_code_revokes_devices_and_inactive_user_is_denied(web):
|
||||||
|
client, headers = web; remote = connect(web)
|
||||||
|
result = client.post(f"/api/v1/remote/admin/remotes/{remote['id']}/rotate", json={}, headers=headers)
|
||||||
|
assert result.status_code == 200 and result.json()['code'] != remote['code']
|
||||||
|
assert client.get('/api/v1/remote/state').status_code == 401
|
||||||
|
database.execute_query("UPDATE remote_attempts SET next_allowed_at='epoch'")
|
||||||
|
assert client.post('/api/v1/remote/connect', json={'code': result.json()['code'], 'pin': '1234'}, headers=headers).status_code == 200
|
||||||
|
database.execute_query('UPDATE users SET is_active=FALSE WHERE user_id=1')
|
||||||
|
assert client.get('/api/v1/remote/state').status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_pin_and_reconnect_do_not_bypass_cooldown(web):
|
||||||
|
client, headers = web; connect(web)
|
||||||
|
database.execute_query("UPDATE remote_attempts SET next_allowed_at='epoch'")
|
||||||
|
database.execute_query('UPDATE remote_devices SET unlocked_date=unlocked_date-1')
|
||||||
|
assert client.post('/api/v1/remote/unlock', json={'pin': '9999'}, headers=headers).status_code == 401
|
||||||
|
assert client.post('/api/v1/remote/unlock', json={'pin': '1234'}, headers=headers).status_code == 429
|
||||||
|
assert client.get('/api/v1/remote/state').status_code == 423
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_read_is_explicit_and_uses_current_user(web, monkeypatch):
|
||||||
|
client, _ = web; connect(web)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(service, 'mark_user_messages_read', lambda uid, partner_user_id=None: calls.append((uid,partner_user_id)) or 1)
|
||||||
|
assert client.get('/api/v1/remote/state').status_code == 200
|
||||||
|
assert calls == []
|
||||||
|
assert post_command(web, 'read', partner_user_id=2)[0].status_code == 200
|
||||||
|
assert calls == [(1,2)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_desktop_start_and_remote_start_share_user_lock(web):
|
||||||
|
client, _ = web; connect(web)
|
||||||
|
snapshot = client.get('/api/v1/remote/state').json()
|
||||||
|
user = {'id': 1, 'username': 'test1', 'permissions': ['cases.view']}
|
||||||
|
def start_remote():
|
||||||
|
payload = Command(request_id=uuid4(), version=snapshot['version'], timer_version=snapshot['timer_version'], action='start', case_id=11)
|
||||||
|
try:
|
||||||
|
asyncio.run(service.command(payload,user))
|
||||||
|
return 200
|
||||||
|
except HTTPException as exc:
|
||||||
|
return exc.status_code
|
||||||
|
def start_desktop():
|
||||||
|
import importlib
|
||||||
|
timers = importlib.import_module('app.timetracking.backend.router')
|
||||||
|
asyncio.run(timers.start_live_timer_v1({'sag_id':12,'billing_mode':'automatic'},user))
|
||||||
|
return 200
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
outcomes = [executor.submit(start_remote),executor.submit(start_desktop)]
|
||||||
|
assert all(f.result() in {200,409} for f in outcomes)
|
||||||
|
assert database.execute_query_single('SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer')['count'] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_hub_jwt_remains_separate_and_revocable(web):
|
||||||
|
client, _ = web; connect(web)
|
||||||
|
client.app.dependency_overrides.clear()
|
||||||
|
token = AuthService.create_access_token(1, 'test1')
|
||||||
|
client.cookies.set('access_token', token)
|
||||||
|
assert client.get('/ordinary').json() == {'id': 1}
|
||||||
|
assert client.get('/api/v1/remote/admin').status_code == 200
|
||||||
|
client.cookies.set('access_token', client.cookies.get(security.COOKIE))
|
||||||
|
assert client.get('/ordinary').status_code == 401
|
||||||
|
client.cookies.set('access_token', token)
|
||||||
|
database.execute_query('UPDATE sessions SET revoked=TRUE')
|
||||||
|
assert client.get('/ordinary').status_code == 401
|
||||||
|
assert client.get('/api/v1/remote/admin').status_code == 401
|
||||||
|
assert client.get('/api/v1/remote/state').status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_contacts_and_owner_only_call(web, db, monkeypatch):
|
||||||
|
from app.modules.telefoni.backend import router as phone_router
|
||||||
|
client, headers = web; connect(web)
|
||||||
|
assert client.get('/api/v1/remote/contacts').status_code == 403
|
||||||
|
db[1].append('customers.view')
|
||||||
|
result = client.get('/api/v1/remote/contacts?q=Kontakt').json()
|
||||||
|
assert len(result['items']) == 1 and result['items'][0]['id'] == 1
|
||||||
|
calls = []
|
||||||
|
async def fake_call(payload, request):
|
||||||
|
calls.append((payload.model_dump(), request.state.user_id))
|
||||||
|
return {'action_url': 'secret-must-not-be-returned'}
|
||||||
|
monkeypatch.setattr(phone_router, 'click_to_call', fake_call)
|
||||||
|
payload = {'request_id': str(uuid4()), 'contact_id': 1, 'field': 'mobile'}
|
||||||
|
first = client.post('/api/v1/remote/call', json=payload, headers=headers)
|
||||||
|
assert first.status_code == 200 and first.json()['status'] == 'ok'
|
||||||
|
assert 'secret' not in first.text
|
||||||
|
assert client.post('/api/v1/remote/call', json=payload, headers=headers).json() == first.json()
|
||||||
|
assert len(calls) == 1 and calls[0][0]['user_id'] == 1 and calls[0][1] == 1
|
||||||
|
assert calls[0][0]['number'] == '87654321' and calls[0][0]['extension'] is None
|
||||||
|
assert client.get('/api/v1/remote/call/' + payload['request_id']).json() == first.json()
|
||||||
|
assert client.post('/api/v1/remote/call', json={**payload, 'user_id': 2}, headers=headers).status_code == 422
|
||||||
|
assert client.post('/api/v1/remote/call', json={**payload, 'field': 'phone'}, headers=headers).status_code == 409
|
||||||
|
assert client.post('/api/v1/remote/call', json={**payload, 'request_id': str(uuid4()), 'contact_id': 2}, headers=headers).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncertain_call_is_not_repeated(web, db, monkeypatch):
|
||||||
|
from app.modules.telefoni.backend import router as phone_router
|
||||||
|
client, headers = web; connect(web); db[1].append('customers.view')
|
||||||
|
calls = []
|
||||||
|
async def failed_call(payload, request):
|
||||||
|
calls.append(payload)
|
||||||
|
raise HTTPException(502, 'timeout')
|
||||||
|
monkeypatch.setattr(phone_router, 'click_to_call', failed_call)
|
||||||
|
payload = {'request_id': str(uuid4()), 'contact_id': 1, 'field': 'phone'}
|
||||||
|
assert client.post('/api/v1/remote/call', json=payload, headers=headers).json()['status'] == 'uncertain'
|
||||||
|
assert client.post('/api/v1/remote/call', json=payload, headers=headers).json()['status'] == 'uncertain'
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert client.get('/api/v1/remote/call/' + str(uuid4())).json()['status'] == 'unknown'
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_incoming_calls_use_shared_state_and_ignore_stale(monkeypatch):
|
||||||
|
from app.modules.remote import telephony
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
monkeypatch.setattr(telephony.MissionService, 'get_active_calls', lambda: [
|
||||||
|
{'call_id':'fresh','updated_at':now,'caller_number':'12345678','ringing_phones':['Support']},
|
||||||
|
{'call_id':'stale','updated_at':now-timedelta(minutes=10)}])
|
||||||
|
user = {'id': 1, 'permissions': ['customers.view']}
|
||||||
|
assert [c['call_id'] for c in telephony.status(user)['incoming']] == ['fresh']
|
||||||
|
assert telephony.status({'id':2,'permissions':[]}) == {'allowed':False,'incoming':[]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_same_call_reserves_before_external_action(web, db, monkeypatch):
|
||||||
|
from app.modules.telefoni.backend import router as phone_router
|
||||||
|
client, headers = web; connect(web); db[1].append('customers.view')
|
||||||
|
calls = []
|
||||||
|
async def slow_call(payload, request):
|
||||||
|
calls.append(payload.user_id)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
return {'status': 'ok'}
|
||||||
|
monkeypatch.setattr(phone_router, 'click_to_call', slow_call)
|
||||||
|
payload = {'request_id': str(uuid4()), 'contact_id': 1, 'field': 'phone'}
|
||||||
|
def send(_):
|
||||||
|
with TestClient(client.app, base_url=str(client.base_url)) as other:
|
||||||
|
other.cookies.update(client.cookies)
|
||||||
|
return other.post('/api/v1/remote/call', json=payload, headers=headers)
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as workers:
|
||||||
|
responses = list(workers.map(send, range(2)))
|
||||||
|
assert all(response.status_code == 200 for response in responses)
|
||||||
|
assert calls == [1]
|
||||||
|
assert client.get('/api/v1/remote/call/' + payload['request_id']).json()['status'] == 'ok'
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_planned_day_is_scoped_to_user_and_danish_day(monkeypatch):
|
||||||
|
from datetime import date
|
||||||
|
captured = []
|
||||||
|
monkeypatch.setattr(service, 'case_permission', lambda user: None)
|
||||||
|
monkeypatch.setattr(service, 'execute_query', lambda sql, params: captured.append((sql, params)) or [])
|
||||||
|
result = service.planned_day({'id': 7}, date(2026, 10, 14))
|
||||||
|
assert result == {'date': '2026-10-14', 'items': []}
|
||||||
|
sql, params = captured[0]
|
||||||
|
assert params[0] == params[3] == 7
|
||||||
|
assert params[1].hour == params[2].hour == 0
|
||||||
|
assert params[1].day == 15 and params[2].day == 14
|
||||||
|
assert params[2].utcoffset() == timedelta(hours=2)
|
||||||
|
assert "q.state='planned'" in sql and 'a.deleted_at IS NULL' in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_planned_day_uses_dst_boundaries(monkeypatch):
|
||||||
|
from datetime import date
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(service, 'case_permission', lambda user: None)
|
||||||
|
monkeypatch.setattr(service, 'execute_query', lambda sql, params: calls.append(params) or [])
|
||||||
|
service.planned_day({'id': 7}, date(2026, 10, 25))
|
||||||
|
end, start = calls[0][1:3]
|
||||||
|
assert (end.astimezone(timezone.utc) - start.astimezone(timezone.utc)) == timedelta(hours=25)
|
||||||
@ -270,7 +270,7 @@ def test_case_create_uses_simple_type_first_flow_with_required_description():
|
|||||||
script = Path("static/js/case-create.js").read_text(encoding="utf-8")
|
script = Path("static/js/case-create.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert 'id="beskrivelse"' in template
|
assert 'id="beskrivelse"' in template
|
||||||
assert 'placeholder="Beskriv problemstillingen detaljeret..." required' in template
|
assert 'placeholder="Beskriv sagen. Skriv / på en ny linje for handlinger…" required' in template
|
||||||
assert 'id="advancedCaseFields"' in template
|
assert 'id="advancedCaseFields"' in template
|
||||||
assert "keys.map(button).join('')" in script
|
assert "keys.map(button).join('')" in script
|
||||||
assert "keys.slice(0,4)" not in script
|
assert "keys.slice(0,4)" not in script
|
||||||
|
|||||||
@ -82,3 +82,10 @@ def test_missing_customer_mapping_never_becomes_internal_time():
|
|||||||
result=evaluate([rule('internal')],{'customer_id':None,'customer_mapping_missing':True},'time.created')
|
result=evaluate([rule('internal')],{'customer_id':None,'customer_mapping_missing':True},'time.created')
|
||||||
assert result['status']=='manual_review'
|
assert result['status']=='manual_review'
|
||||||
assert result['reason']=='missing_customer_mapping'
|
assert result['reason']=='missing_customer_mapping'
|
||||||
|
|
||||||
|
def test_case_title_condition_is_distinct_from_case_id():
|
||||||
|
definition={'trigger':'case.updated','branches':[{'condition':{'field':'case_title','operator':'contains','value':'TEST'},'actions':[{'type':'notify','title':'Matched','recipient_user_ids':[1]}]}]}
|
||||||
|
validate_definition(definition)
|
||||||
|
result=evaluate([{'id':1,'definition':definition}],{'case_id':196,'case_title':'TEST tid'},'case.updated')
|
||||||
|
assert result['actions'][0]['title']=='Matched'
|
||||||
|
assert not evaluate([{'id':1,'definition':definition}],{'case_id':196,'case_title':'Printer'},'case.updated')['actions']
|
||||||
|
|||||||
@ -38,3 +38,30 @@ def test_resource_audit_and_explicit_null_monthly_price(monkeypatch):
|
|||||||
assert update.args[1][1] is None
|
assert update.args[1][1] is None
|
||||||
assert update.args[1][0].adapted['auto_billing'] is True
|
assert update.args[1][0].adapted['auto_billing'] is True
|
||||||
assert any('INSERT INTO bmc_workflow_resource_history' in call.args[0] for call in cursor.execute.call_args_list)
|
assert any('INSERT INTO bmc_workflow_resource_history' in call.args[0] for call in cursor.execute.call_args_list)
|
||||||
|
|
||||||
|
def test_customer_card_coverage_saves_profile_and_audit(monkeypatch):
|
||||||
|
cursor=MagicMock()
|
||||||
|
cursor.fetchone.side_effect=[{'id':24}, None]
|
||||||
|
cursor.fetchall.return_value=[]
|
||||||
|
@contextmanager
|
||||||
|
def transaction(): yield cursor
|
||||||
|
monkeypatch.setattr(resources,'transaction',transaction)
|
||||||
|
result=resources.update_customer_card_coverage(24,resources.Coverage(),{'id':4})
|
||||||
|
assert result['workflow_coverage']['auto_billing'] is True
|
||||||
|
calls=[c.args for c in cursor.execute.call_args_list]
|
||||||
|
assert any('INSERT INTO bmc_workflow_customer_card_coverage' in c[0] and c[1][0]==24 for c in calls)
|
||||||
|
assert any('INSERT INTO bmc_workflow_resource_history' in c[0] and c[1][0]=='customer_card' for c in calls)
|
||||||
|
assert not any('UPDATE tticket_prepaid_cards' in c[0] for c in calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_customer_card_coverage_rejects_inactive_tags(monkeypatch):
|
||||||
|
cursor=MagicMock()
|
||||||
|
cursor.fetchone.return_value={'id':24}
|
||||||
|
cursor.fetchall.return_value=[]
|
||||||
|
@contextmanager
|
||||||
|
def transaction(): yield cursor
|
||||||
|
monkeypatch.setattr(resources,'transaction',transaction)
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
resources.update_customer_card_coverage(24,resources.Coverage(tag_ids=[9]),{'id':4})
|
||||||
|
assert error.value.status_code==422
|
||||||
|
assert not any(c.args[0].startswith(('INSERT','UPDATE')) for c in cursor.execute.call_args_list)
|
||||||
|
|||||||
41
tests/test_workflow_timer_registration.py
Normal file
41
tests/test_workflow_timer_registration.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
import importlib
|
||||||
|
r = importlib.import_module('app.timetracking.backend.router')
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.workflows import integration, settlement
|
||||||
|
|
||||||
|
|
||||||
|
def setup(monkeypatch, **changes):
|
||||||
|
entry = dict(id=7, medarbejder_id=2, status='pending', aktiv_timer=False, slut_tid=datetime.now(), work_type='support')
|
||||||
|
entry.update(changes)
|
||||||
|
monkeypatch.setattr(r, 'execute_query_single', lambda *a: entry)
|
||||||
|
monkeypatch.setattr(r, '_resolve_current_user_id', lambda user: 2)
|
||||||
|
monkeypatch.setattr(r, '_can_view_all_team_timers', lambda user: False)
|
||||||
|
monkeypatch.setattr(settings, 'WORKFLOWS_ENABLED', True)
|
||||||
|
monkeypatch.setattr(settings, 'WORKFLOWS_MODE', 'active')
|
||||||
|
monkeypatch.setattr(integration, 'active_for_customer', lambda customer: True)
|
||||||
|
monkeypatch.setattr(settlement, 'workflow_customer', lambda time_id: 17)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_preserves_pending_and_billing(monkeypatch):
|
||||||
|
entry = setup(monkeypatch)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(r, 'execute_query', lambda sql, params: calls.append((sql, params)) or [entry])
|
||||||
|
result = asyncio.run(r.register_workflow_timer_v1(7, {'work_type':'meeting','fakturerbar_tid_min':30}, {'id':2}))
|
||||||
|
sql, params = calls[0]
|
||||||
|
assert params == ('meeting',30,7)
|
||||||
|
assert 'approved_hours' not in sql and 'billing_method' not in sql
|
||||||
|
assert "status='pending'" in sql and "kilde='workflow_registered'" in sql
|
||||||
|
assert result['status'] == 'pending'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('changes', [{'medarbejder_id':3}, {'status':'approved'}, {'aktiv_timer':True}, {'economy_order_draft_id':9}])
|
||||||
|
def test_register_rejects_other_owner_or_processed_timer(monkeypatch, changes):
|
||||||
|
setup(monkeypatch, **changes)
|
||||||
|
monkeypatch.setattr(r, 'execute_query', lambda *a: pytest.fail('Must not write'))
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
asyncio.run(r.register_workflow_timer_v1(7, {'fakturerbar_tid_min':30}, {'id':2}))
|
||||||
Loading…
Reference in New Issue
Block a user