feat: add time multiplier presets management and extend worklog model

- Added a new section in the settings frontend for managing time multiplier presets, including UI for adding, displaying, and saving presets.
- Introduced a new function to normalize and load time multiplier presets from settings.
- Updated the worklog submission process to include a selected multiplier preset and its corresponding rate multiplier.
- Enhanced the worklog model to support additional fields: extra_billed_hours, extended_support_flag, rate_multiplier, manual_hourly_rate, manual_rounded_hours, and rounding_override_reason.
- Implemented backend validation to ensure that time entries linked to prepaid cards cannot be edited if the card is in a locked status.
- Added database migration to introduce new columns and constraints for the worklog and time entry tables.
This commit is contained in:
Christian 2026-06-21 10:26:07 +02:00
parent a604a3cc44
commit 4822637466
16 changed files with 1506 additions and 107 deletions

View File

@ -1,14 +1,98 @@
import logging
import hmac
import hashlib
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from app.core.auth_dependencies import get_current_user
from app.core.config import settings
from app.core.database import execute_query
logger = logging.getLogger(__name__)
router = APIRouter()
def _calendar_token_secret() -> bytes:
return (settings.SECRET_KEY or settings.JWT_SECRET_KEY or "calendar-dev-secret").encode("utf-8")
def _create_calendar_feed_token(user_id: int, expires_at: datetime) -> str:
exp_ts = int(expires_at.timestamp())
payload = f"{int(user_id)}:{exp_ts}".encode("utf-8")
digest = hmac.new(_calendar_token_secret(), payload, hashlib.sha256).hexdigest()
return f"{int(user_id)}.{exp_ts}.{digest}"
def _verify_calendar_feed_token(token: str) -> int:
parts = str(token or "").split(".")
if len(parts) != 3:
raise HTTPException(status_code=401, detail="Invalid calendar token")
user_part, exp_part, sig_part = parts
try:
user_id = int(user_part)
exp_ts = int(exp_part)
except ValueError as exc:
raise HTTPException(status_code=401, detail="Invalid calendar token") from exc
payload = f"{user_id}:{exp_ts}".encode("utf-8")
expected = hmac.new(_calendar_token_secret(), payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig_part):
raise HTTPException(status_code=401, detail="Invalid calendar token")
if datetime.utcnow().timestamp() > exp_ts:
raise HTTPException(status_code=401, detail="Calendar token expired")
return user_id
def _build_ical_response(events: list[dict], now: datetime) -> Response:
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//BMC Hub//Calendar//DA",
"CALSCALE:GREGORIAN",
"X-WR-CALNAME:BMC Hub Kalender",
"REFRESH-INTERVAL;VALUE=DURATION:PT15M",
"X-PUBLISHED-TTL:PT15M",
]
for event in events:
start_value = datetime.fromisoformat(event.get("start"))
summary = _escape_ical(event.get("title", ""))
description_parts = []
if event.get("customer_name"):
description_parts.append(f"Kunde: {event.get('customer_name')}")
if event.get("event_type"):
description_parts.append(f"Type: {event.get('event_type')}")
if event.get("url"):
description_parts.append(f"Link: {event.get('url')}")
description = _escape_ical("\n".join(description_parts))
uid = _escape_ical(f"{event.get('id')}@bmc-hub")
lines.extend([
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{_format_ical_dt(now)}",
f"DTSTART:{_format_ical_dt(start_value)}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
return Response(
content="\r\n".join(lines),
media_type="text/calendar; charset=utf-8",
headers={
"Content-Disposition": 'inline; filename="bmc-hub-calendar.ics"',
"Cache-Control": "public, max-age=300",
},
)
def _parse_iso_datetime(value: str, fallback: datetime) -> datetime:
if not value:
return fallback
@ -302,40 +386,53 @@ async def get_calendar_ical(
types=types,
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//BMC Hub//Calendar//DA",
"CALSCALE:GREGORIAN",
"X-WR-CALNAME:BMC Hub Kalender",
]
return _build_ical_response(events, now)
for event in events:
start_value = datetime.fromisoformat(event.get("start"))
summary = _escape_ical(event.get("title", ""))
description_parts = []
if event.get("customer_name"):
description_parts.append(f"Kunde: {event.get('customer_name')}")
if event.get("event_type"):
description_parts.append(f"Type: {event.get('event_type')}")
if event.get("url"):
description_parts.append(f"Link: {event.get('url')}")
description = _escape_ical("\n".join(description_parts))
uid = _escape_ical(f"{event.get('id')}@bmc-hub")
lines.extend([
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{_format_ical_dt(now)}",
f"DTSTART:{_format_ical_dt(start_value)}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{description}",
"END:VEVENT",
])
@router.get("/calendar/ical/subscribe")
async def get_calendar_ical_subscribe_link(
request: Request,
current_user: dict = Depends(get_current_user),
):
"""Return a tokenized iCal subscription URL suitable for Outlook/Internet Calendar."""
expires_at = datetime.utcnow() + timedelta(days=3650)
token = _create_calendar_feed_token(int(current_user.get("id")), expires_at)
base = str(request.base_url).rstrip("/")
http_url = f"{base}/api/v1/calendar/ical/feed?token={token}"
webcal_url = http_url.replace("https://", "webcals://", 1).replace("http://", "webcal://", 1)
lines.append("END:VCALENDAR")
return {
"http_url": http_url,
"webcal_url": webcal_url,
"expires_at": expires_at.isoformat() + "Z",
}
return Response(
content="\r\n".join(lines),
media_type="text/calendar; charset=utf-8",
@router.get("/calendar/ical/feed")
async def get_calendar_ical_feed(
request: Request,
token: str = Query(...),
start: str = Query(None),
end: str = Query(None),
customer_id: int | None = Query(None),
types: str | None = Query(None),
):
"""Token-based iCal feed endpoint intended for external calendar subscriptions."""
now = datetime.now()
start_dt = _parse_iso_datetime(start, now - timedelta(days=14))
end_dt = _parse_iso_datetime(end, now + timedelta(days=60))
if end_dt < start_dt:
raise HTTPException(status_code=400, detail="Invalid date range")
user_id = _verify_calendar_feed_token(token)
events = _get_calendar_events(
request=request,
start_dt=start_dt,
end_dt=end_dt,
only_mine=True,
user_id=user_id,
customer_id=customer_id,
types=types,
)
return _build_ical_response(events, now)

View File

@ -431,7 +431,15 @@
<div>Status: <span id="calendarStatus">Klar</span></div>
</div>
<div class="hero-ical">
iCal: <a href="{{ request.base_url }}api/v1/calendar/ical">{{ request.base_url }}api/v1/calendar/ical</a>
<div><strong>Outlook iCal abonnement (opdateres lobende):</strong></div>
<div class="mt-1">
<a id="icalSubscribeLink" href="{{ request.base_url }}api/v1/calendar/ical" target="_blank" rel="noopener">{{ request.base_url }}api/v1/calendar/ical</a>
</div>
<div class="d-flex flex-wrap gap-2 mt-2">
<button class="btn btn-sm btn-outline-primary" type="button" id="copyIcalLinkBtn">Kopier link</button>
<button class="btn btn-sm btn-outline-secondary" type="button" id="copyWebcalLinkBtn">Kopier webcal://</button>
</div>
<div class="small text-muted mt-2">Tip: Brug internetkalender-abonnement i Outlook, ikke import, for automatisk opdatering.</div>
</div>
</div>
<div class="calendar-filter-card">
@ -574,6 +582,9 @@
const rangeLabelEl = document.getElementById('rangeLabel');
const customerSelect = document.getElementById('customerSelect');
const customerSearch = document.getElementById('customerSearch');
const icalSubscribeLink = document.getElementById('icalSubscribeLink');
const copyIcalLinkBtn = document.getElementById('copyIcalLinkBtn');
const copyWebcalLinkBtn = document.getElementById('copyWebcalLinkBtn');
const mineToggle = document.getElementById('mineToggle');
const allToggle = document.getElementById('allToggle');
const viewButtons = document.getElementById('viewButtons');
@ -589,6 +600,38 @@
let onlyMine = true;
async function copyTextToClipboard(value) {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
calendarStatusEl.textContent = 'Link kopieret';
} catch (err) {
console.warn('Clipboard write failed', err);
calendarStatusEl.textContent = 'Kunne ikke kopiere link';
}
}
async function loadIcalSubscriptionLink() {
if (!icalSubscribeLink) return;
try {
const response = await fetch('/api/v1/calendar/ical/subscribe');
if (!response.ok) return;
const data = await response.json();
if (data.http_url) {
icalSubscribeLink.href = data.http_url;
icalSubscribeLink.textContent = data.http_url;
}
if (copyIcalLinkBtn) {
copyIcalLinkBtn.onclick = () => copyTextToClipboard(data.http_url || '');
}
if (copyWebcalLinkBtn) {
copyWebcalLinkBtn.onclick = () => copyTextToClipboard(data.webcal_url || '');
}
} catch (err) {
console.warn('Could not load tokenized iCal subscription URL', err);
}
}
function setToggle(activeMine) {
onlyMine = activeMine;
mineToggle.classList.toggle('active', activeMine);
@ -717,6 +760,7 @@
calendar.render();
loadCustomers();
loadIcalSubscriptionLink();
mineToggle.addEventListener('click', () => setToggle(true));
allToggle.addEventListener('click', () => setToggle(false));

View File

@ -6248,7 +6248,7 @@
<input type="number" class="form-control form-control-sm" id="quickTimeMinutes" name="minutes"
min="0" max="59" step="15" value="0" required>
</div>
<div class="col-md-3 col-6">
<div class="col-md-2 col-6">
<label for="quickTimeBillingMethod" class="form-label small mb-1">Afregning</label>
<select class="form-select form-select-sm" id="quickTimeBillingMethod" name="billing_method">
<option value="invoice" selected>Faktura</option>
@ -6270,7 +6270,13 @@
<option value="warranty">Garanti</option>
</select>
</div>
<div class="col-md-4 col-12">
<div class="col-md-2 col-6">
<label for="quickTimeMultiplierPreset" class="form-label small mb-1">Multiplier</label>
<select class="form-select form-select-sm" id="quickTimeMultiplierPreset" name="multiplier_preset">
<option value="">Ingen (x1.00)</option>
</select>
</div>
<div class="col-md-3 col-12">
<label for="quickTimeDescription" class="form-label small mb-1">Beskrivelse</label>
<input type="text" class="form-control form-control-sm" id="quickTimeDescription" name="description"
placeholder="Hvad har du lavet?" required>
@ -10220,6 +10226,12 @@
<label class="form-label">Beskrivelse</label>
<input type="text" class="form-control" id="sol_time_desc" placeholder="F.eks. afsluttede løsning">
</div>
<div class="col-md-4">
<label class="form-label">Multiplier</label>
<select class="form-select" id="sol_time_multiplier_preset">
<option value="">Ingen (x1.00)</option>
</select>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="sol_time_internal">
@ -10403,6 +10415,13 @@
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="time_desc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
</div>
<div class="col-12">
<label class="form-label">Multiplier preset</label>
<select class="form-select" id="time_multiplier_preset" onchange="applySagTimeMultiplierPreset()">
<option value="">Ingen (x1.00)</option>
</select>
<div class="form-text">Preset kan sætte tekst automatisk og gemme multiplier med registreringen.</div>
</div>
</div>
</form>
</div>
@ -10418,6 +10437,90 @@
<!-- Script for Solution/Time -->
<script>
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 },
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
];
let sagTimeMultiplierPresets = [...DEFAULT_TIME_MULTIPLIER_PRESETS];
function normalizeMultiplierPresets(raw) {
if (!Array.isArray(raw)) return [];
const normalized = [];
for (const row of raw) {
if (!row || typeof row !== 'object') continue;
const label = String(row.label || row.name || '').trim();
const text = String(row.text || row.description || label || '').trim();
const multiplier = Number(row.multiplier ?? row.value ?? 1);
if (!label || !Number.isFinite(multiplier) || multiplier <= 0) continue;
normalized.push({
label,
text,
multiplier: Number(multiplier.toFixed(2))
});
}
return normalized;
}
async function loadSagTimeMultiplierPresets() {
try {
const response = await fetch('/api/v1/settings/time_multiplier_presets');
if (!response.ok) {
sagTimeMultiplierPresets = [...DEFAULT_TIME_MULTIPLIER_PRESETS];
return;
}
const setting = await response.json();
const parsed = JSON.parse(setting.value || '[]');
const normalized = normalizeMultiplierPresets(parsed);
sagTimeMultiplierPresets = normalized.length ? normalized : [...DEFAULT_TIME_MULTIPLIER_PRESETS];
} catch (error) {
console.warn('Kunne ikke hente time multiplier presets', error);
sagTimeMultiplierPresets = [...DEFAULT_TIME_MULTIPLIER_PRESETS];
}
}
function renderSagTimeMultiplierPresetOptions() {
const select = document.getElementById('time_multiplier_preset');
if (!select) return;
const options = sagTimeMultiplierPresets.map((preset, idx) => {
const value = Number(preset.multiplier).toFixed(2);
return `<option value="${idx}">${preset.label} (x${value})</option>`;
}).join('');
select.innerHTML = `<option value="">Ingen (x1.00)</option>${options}`;
}
function renderQuickTimeMultiplierPresetOptions() {
const select = document.getElementById('quickTimeMultiplierPreset');
if (!select) return;
const options = sagTimeMultiplierPresets.map((preset, idx) => {
const value = Number(preset.multiplier).toFixed(2);
return `<option value="${idx}">${preset.label} (x${value})</option>`;
}).join('');
select.innerHTML = `<option value="">Ingen (x1.00)</option>${options}`;
}
function renderSolutionTimeMultiplierPresetOptions() {
const select = document.getElementById('sol_time_multiplier_preset');
if (!select) return;
const options = sagTimeMultiplierPresets.map((preset, idx) => {
const value = Number(preset.multiplier).toFixed(2);
return `<option value="${idx}">${preset.label} (x${value})</option>`;
}).join('');
select.innerHTML = `<option value="">Ingen (x1.00)</option>${options}`;
}
function applySagTimeMultiplierPreset() {
const select = document.getElementById('time_multiplier_preset');
const descEl = document.getElementById('time_desc');
if (!select || !descEl) return;
if (select.value === '') return;
const preset = sagTimeMultiplierPresets[Number(select.value)];
if (!preset || !preset.text) return;
if ((descEl.value || '').trim()) return;
descEl.value = preset.text;
}
function showCreateSolutionModal() {
const addTimeCheckbox = document.getElementById('sol_add_time');
const timeFields = document.getElementById('sol_time_fields');
@ -10435,6 +10538,11 @@
if (timeTotal) timeTotal.textContent = 'Total: 0.00 timer';
const timeDesc = document.getElementById('sol_time_desc');
if (timeDesc) timeDesc.value = '';
const timeMultiplier = document.getElementById('sol_time_multiplier_preset');
if (timeMultiplier) {
renderSolutionTimeMultiplierPresetOptions();
timeMultiplier.value = '';
}
const timeInternal = document.getElementById('sol_time_internal');
if (timeInternal) timeInternal.checked = false;
new bootstrap.Modal(document.getElementById('createSolutionModal')).show();
@ -10471,6 +10579,10 @@
if (res.ok) {
if (addTime && timeTotal > 0) {
const solution = await res.json();
const solPresetSelect = document.getElementById('sol_time_multiplier_preset');
const selectedSolPreset = solPresetSelect && solPresetSelect.value !== ''
? sagTimeMultiplierPresets[Number(solPresetSelect.value)]
: null;
const timePayload = {
sag_id: data.sag_id,
solution_id: solution.id,
@ -10480,6 +10592,10 @@
is_internal: document.getElementById('sol_time_internal').checked,
work_type: 'support'
};
if (selectedSolPreset) {
timePayload.rate_multiplier = Number(selectedSolPreset.multiplier);
timePayload.multiplier_text = selectedSolPreset.text;
}
const timeRes = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
@ -10499,6 +10615,7 @@
function showAddTimeModal() {
// Set date to today
document.getElementById('time_date').valueAsDate = new Date();
renderSagTimeMultiplierPresetOptions();
// Reset fields
if(document.getElementById('time_total_minutes')) {
@ -10507,6 +10624,7 @@
document.getElementById('time_end_input').value = '';
}
document.getElementById('time_desc').value = '';
if(document.getElementById('time_multiplier_preset')) document.getElementById('time_multiplier_preset').value = '';
if(document.getElementById('time_internal')) document.getElementById('time_internal').checked = false;
if(document.getElementById('time_billing_method')) document.getElementById('time_billing_method').value = 'invoice';
if(document.getElementById('time_work_type')) document.getElementById('time_work_type').value = 'support';
@ -10533,6 +10651,12 @@
const solMinutes = document.getElementById('sol_time_minutes');
if (solHours) solHours.addEventListener('input', updateSolutionTimeTotal);
if (solMinutes) solMinutes.addEventListener('input', updateSolutionTimeTotal);
loadSagTimeMultiplierPresets().then(() => {
renderSagTimeMultiplierPresetOptions();
renderQuickTimeMultiplierPresetOptions();
renderSolutionTimeMultiplierPresetOptions();
});
});
function bindTimeModalCalculations() {
@ -10633,6 +10757,19 @@
work_type: document.getElementById('time_work_type').value,
billing_method: document.getElementById('time_billing_method').value
};
const presetSelect = document.getElementById('time_multiplier_preset');
const selectedPreset = presetSelect && presetSelect.value !== ''
? sagTimeMultiplierPresets[Number(presetSelect.value)]
: null;
if ((payload.description || '').trim() === '' && selectedPreset && selectedPreset.text) {
payload.description = selectedPreset.text;
}
if (selectedPreset) {
payload.rate_multiplier = Number(selectedPreset.multiplier);
payload.multiplier_text = selectedPreset.text;
}
try {
const res = await fetch('/api/v1/timetracking/time/manual', {
@ -14590,6 +14727,11 @@
}
const isInternal = billingMethod === 'internal';
const quickPresetSelect = document.getElementById('quickTimeMultiplierPreset');
const selectedPreset = quickPresetSelect && quickPresetSelect.value !== ''
? sagTimeMultiplierPresets[Number(quickPresetSelect.value)]
: null;
// Build payload
const payload = {
@ -14608,6 +14750,11 @@
if (fixedPriceAgreementId) {
payload.fixed_price_agreement_id = fixedPriceAgreementId;
}
if (selectedPreset) {
payload.rate_multiplier = Number(selectedPreset.multiplier);
payload.multiplier_text = selectedPreset.text;
}
try {
const response = await fetch('/api/v1/timetracking/entries/internal', {
@ -15363,6 +15510,11 @@
<option value="internal">Intern</option>
<option value="prepaid">Forudbetalt</option>
</select></div>
<div class="mb-2"><label class="form-label small fw-semibold">Multiplier</label>
<select id="rqt_multiplier" class="form-select form-select-sm">
<option value="">Ingen (x1.00)</option>
${sagTimeMultiplierPresets.map((preset, idx) => `<option value="${idx}">${esc(preset.label)} (x${Number(preset.multiplier).toFixed(2)})</option>`).join('')}
</select></div>
<div class="mb-2"><label class="form-label small fw-semibold">Beskrivelse</label>
<textarea id="rqt_desc" class="form-control form-control-sm" rows="2"></textarea></div>`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelTime(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
@ -15378,14 +15530,26 @@
return;
}
const billing = document.getElementById('rqt_billing')?.value || 'invoice';
const presetSelect = document.getElementById('rqt_multiplier');
const selectedPreset = presetSelect && presetSelect.value !== ''
? sagTimeMultiplierPresets[Number(presetSelect.value)]
: null;
const description = document.getElementById('rqt_desc').value;
const payload = {
sag_id: caseId,
worked_date: document.getElementById('rqt_date').value,
original_hours: totalHours,
description: document.getElementById('rqt_desc').value,
description: description,
billing_method: billing,
is_internal: billing === 'internal',
};
if ((!description || !description.trim()) && selectedPreset && selectedPreset.text) {
payload.description = selectedPreset.text;
}
if (selectedPreset) {
payload.rate_multiplier = Number(selectedPreset.multiplier);
payload.multiplier_text = selectedPreset.text;
}
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; }
try {

View File

@ -7045,7 +7045,7 @@
<input type="number" class="form-control form-control-sm" id="quickTimeMinutes" name="minutes"
min="0" max="59" step="15" value="0" required>
</div>
<div class="col-md-3 col-6">
<div class="col-md-2 col-6">
<label for="quickTimeBillingMethod" class="form-label small mb-1">Afregning</label>
<select class="form-select form-select-sm" id="quickTimeBillingMethod" name="billing_method">
<option value="invoice" selected>Faktura</option>
@ -7067,7 +7067,13 @@
<option value="warranty">Garanti</option>
</select>
</div>
<div class="col-md-4 col-12">
<div class="col-md-2 col-6">
<label for="quickTimeMultiplierPreset" class="form-label small mb-1">Multiplier</label>
<select class="form-select form-select-sm" id="quickTimeMultiplierPreset" name="multiplier_preset">
<option value="">Ingen (x1.00)</option>
</select>
</div>
<div class="col-md-3 col-12">
<label for="quickTimeDescription" class="form-label small mb-1">Beskrivelse</label>
<input type="text" class="form-control form-control-sm" id="quickTimeDescription" name="description"
placeholder="Hvad har du lavet?" required>
@ -12326,6 +12332,12 @@
<label class="form-label">Beskrivelse</label>
<input type="text" class="form-control" id="sol_time_desc" placeholder="F.eks. afsluttede løsning">
</div>
<div class="col-md-4">
<label class="form-label">Multiplier</label>
<select class="form-select" id="sol_time_multiplier_preset">
<option value="">Ingen (x1.00)</option>
</select>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="sol_time_internal">
@ -12509,6 +12521,13 @@
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="time_desc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
</div>
<div class="col-12">
<label class="form-label">Multiplier preset</label>
<select class="form-select" id="time_multiplier_preset" onchange="applySagTimeMultiplierPreset()">
<option value="">Ingen (x1.00)</option>
</select>
<div class="form-text">Preset kan sætte tekst automatisk og gemme multiplier med registreringen.</div>
</div>
</div>
</form>
</div>
@ -12524,6 +12543,90 @@
<!-- Script for Solution/Time -->
<script>
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 },
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
];
let sagTimeMultiplierPresets = [...DEFAULT_TIME_MULTIPLIER_PRESETS];
function normalizeMultiplierPresets(raw) {
if (!Array.isArray(raw)) return [];
const normalized = [];
for (const row of raw) {
if (!row || typeof row !== 'object') continue;
const label = String(row.label || row.name || '').trim();
const text = String(row.text || row.description || label || '').trim();
const multiplier = Number(row.multiplier ?? row.value ?? 1);
if (!label || !Number.isFinite(multiplier) || multiplier <= 0) continue;
normalized.push({
label,
text,
multiplier: Number(multiplier.toFixed(2))
});
}
return normalized;
}
async function loadSagTimeMultiplierPresets() {
try {
const response = await fetch('/api/v1/settings/time_multiplier_presets');
if (!response.ok) {
sagTimeMultiplierPresets = [...DEFAULT_TIME_MULTIPLIER_PRESETS];
return;
}
const setting = await response.json();
const parsed = JSON.parse(setting.value || '[]');
const normalized = normalizeMultiplierPresets(parsed);
sagTimeMultiplierPresets = normalized.length ? normalized : [...DEFAULT_TIME_MULTIPLIER_PRESETS];
} catch (error) {
console.warn('Kunne ikke hente time multiplier presets', error);
sagTimeMultiplierPresets = [...DEFAULT_TIME_MULTIPLIER_PRESETS];
}
}
function renderSagTimeMultiplierPresetOptions() {
const select = document.getElementById('time_multiplier_preset');
if (!select) return;
const options = sagTimeMultiplierPresets.map((preset, idx) => {
const value = Number(preset.multiplier).toFixed(2);
return `<option value="${idx}">${preset.label} (x${value})</option>`;
}).join('');
select.innerHTML = `<option value="">Ingen (x1.00)</option>${options}`;
}
function renderQuickTimeMultiplierPresetOptions() {
const select = document.getElementById('quickTimeMultiplierPreset');
if (!select) return;
const options = sagTimeMultiplierPresets.map((preset, idx) => {
const value = Number(preset.multiplier).toFixed(2);
return `<option value="${idx}">${preset.label} (x${value})</option>`;
}).join('');
select.innerHTML = `<option value="">Ingen (x1.00)</option>${options}`;
}
function renderSolutionTimeMultiplierPresetOptions() {
const select = document.getElementById('sol_time_multiplier_preset');
if (!select) return;
const options = sagTimeMultiplierPresets.map((preset, idx) => {
const value = Number(preset.multiplier).toFixed(2);
return `<option value="${idx}">${preset.label} (x${value})</option>`;
}).join('');
select.innerHTML = `<option value="">Ingen (x1.00)</option>${options}`;
}
function applySagTimeMultiplierPreset() {
const select = document.getElementById('time_multiplier_preset');
const descEl = document.getElementById('time_desc');
if (!select || !descEl) return;
if (select.value === '') return;
const preset = sagTimeMultiplierPresets[Number(select.value)];
if (!preset || !preset.text) return;
if ((descEl.value || '').trim()) return;
descEl.value = preset.text;
}
function showCreateSolutionModal() {
const addTimeCheckbox = document.getElementById('sol_add_time');
const timeFields = document.getElementById('sol_time_fields');
@ -12541,6 +12644,11 @@
if (timeTotal) timeTotal.textContent = 'Total: 0.00 timer';
const timeDesc = document.getElementById('sol_time_desc');
if (timeDesc) timeDesc.value = '';
const timeMultiplier = document.getElementById('sol_time_multiplier_preset');
if (timeMultiplier) {
renderSolutionTimeMultiplierPresetOptions();
timeMultiplier.value = '';
}
const timeInternal = document.getElementById('sol_time_internal');
if (timeInternal) timeInternal.checked = false;
new bootstrap.Modal(document.getElementById('createSolutionModal')).show();
@ -12577,6 +12685,10 @@
if (res.ok) {
if (addTime && timeTotal > 0) {
const solution = await res.json();
const solPresetSelect = document.getElementById('sol_time_multiplier_preset');
const selectedSolPreset = solPresetSelect && solPresetSelect.value !== ''
? sagTimeMultiplierPresets[Number(solPresetSelect.value)]
: null;
const timePayload = {
sag_id: data.sag_id,
solution_id: solution.id,
@ -12586,6 +12698,10 @@
is_internal: document.getElementById('sol_time_internal').checked,
work_type: 'support'
};
if (selectedSolPreset) {
timePayload.rate_multiplier = Number(selectedSolPreset.multiplier);
timePayload.multiplier_text = selectedSolPreset.text;
}
const timeRes = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
@ -12605,6 +12721,7 @@
function showAddTimeModal() {
// Set date to today
document.getElementById('time_date').valueAsDate = new Date();
renderSagTimeMultiplierPresetOptions();
// Reset fields
if(document.getElementById('time_total_minutes')) {
@ -12613,6 +12730,7 @@
document.getElementById('time_end_input').value = '';
}
document.getElementById('time_desc').value = '';
if(document.getElementById('time_multiplier_preset')) document.getElementById('time_multiplier_preset').value = '';
if(document.getElementById('time_internal')) document.getElementById('time_internal').checked = false;
if(document.getElementById('time_billing_method')) document.getElementById('time_billing_method').value = 'invoice';
if(document.getElementById('time_work_type')) document.getElementById('time_work_type').value = 'support';
@ -12639,6 +12757,12 @@
const solMinutes = document.getElementById('sol_time_minutes');
if (solHours) solHours.addEventListener('input', updateSolutionTimeTotal);
if (solMinutes) solMinutes.addEventListener('input', updateSolutionTimeTotal);
loadSagTimeMultiplierPresets().then(() => {
renderSagTimeMultiplierPresetOptions();
renderQuickTimeMultiplierPresetOptions();
renderSolutionTimeMultiplierPresetOptions();
});
});
function bindTimeModalCalculations() {
@ -12739,6 +12863,19 @@
work_type: document.getElementById('time_work_type').value,
billing_method: document.getElementById('time_billing_method').value
};
const presetSelect = document.getElementById('time_multiplier_preset');
const selectedPreset = presetSelect && presetSelect.value !== ''
? sagTimeMultiplierPresets[Number(presetSelect.value)]
: null;
if ((payload.description || '').trim() === '' && selectedPreset && selectedPreset.text) {
payload.description = selectedPreset.text;
}
if (selectedPreset) {
payload.rate_multiplier = Number(selectedPreset.multiplier);
payload.multiplier_text = selectedPreset.text;
}
try {
const res = await fetch('/api/v1/timetracking/time/manual', {
@ -16709,6 +16846,11 @@
}
const isInternal = billingMethod === 'internal';
const quickPresetSelect = document.getElementById('quickTimeMultiplierPreset');
const selectedPreset = quickPresetSelect && quickPresetSelect.value !== ''
? sagTimeMultiplierPresets[Number(quickPresetSelect.value)]
: null;
// Build payload
const payload = {
@ -16727,6 +16869,11 @@
if (fixedPriceAgreementId) {
payload.fixed_price_agreement_id = fixedPriceAgreementId;
}
if (selectedPreset) {
payload.rate_multiplier = Number(selectedPreset.multiplier);
payload.multiplier_text = selectedPreset.text;
}
try {
const response = await fetch('/api/v1/timetracking/entries/internal', {
@ -17482,6 +17629,11 @@
<option value="internal">Intern</option>
<option value="prepaid">Forudbetalt</option>
</select></div>
<div class="mb-2"><label class="form-label small fw-semibold">Multiplier</label>
<select id="rqt_multiplier" class="form-select form-select-sm">
<option value="">Ingen (x1.00)</option>
${sagTimeMultiplierPresets.map((preset, idx) => `<option value="${idx}">${esc(preset.label)} (x${Number(preset.multiplier).toFixed(2)})</option>`).join('')}
</select></div>
<div class="mb-2"><label class="form-label small fw-semibold">Beskrivelse</label>
<textarea id="rqt_desc" class="form-control form-control-sm" rows="2"></textarea></div>`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelTime(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
@ -17497,14 +17649,26 @@
return;
}
const billing = document.getElementById('rqt_billing')?.value || 'invoice';
const presetSelect = document.getElementById('rqt_multiplier');
const selectedPreset = presetSelect && presetSelect.value !== ''
? sagTimeMultiplierPresets[Number(presetSelect.value)]
: null;
const description = document.getElementById('rqt_desc').value;
const payload = {
sag_id: caseId,
worked_date: document.getElementById('rqt_date').value,
original_hours: totalHours,
description: document.getElementById('rqt_desc').value,
description: description,
billing_method: billing,
is_internal: billing === 'internal',
};
if ((!description || !description.trim()) && selectedPreset && selectedPreset.text) {
payload.description = selectedPreset.text;
}
if (selectedPreset) {
payload.rate_multiplier = Number(selectedPreset.multiplier);
payload.multiplier_text = selectedPreset.text;
}
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; }
try {

View File

@ -43,6 +43,10 @@ class PrepaidCardRoundingUpdate(BaseModel):
rounding_minutes: int
class PrepaidCardStatusUpdate(BaseModel):
status: str
def _normalize_rounding_minutes(value: Optional[int]) -> int:
if value is None:
return 0
@ -58,6 +62,22 @@ def _apply_rounding(hours: float, rounding_minutes: int) -> float:
return float(rounded)
def _column_exists(table_name: str, column_name: str) -> bool:
try:
row = execute_query(
"""
SELECT 1
FROM information_schema.columns
WHERE table_name = %s AND column_name = %s
LIMIT 1
""",
(table_name, column_name)
)
return bool(row)
except Exception:
return False
@router.get("/prepaid-cards", response_model=List[Dict[str, Any]])
async def get_prepaid_cards(status: Optional[str] = None, customer_id: Optional[int] = None):
"""
@ -174,7 +194,21 @@ async def get_prepaid_card(card_id: int):
card['transactions'] = transactions or []
# Timelogs from Sag + Ticket worklog (all entries tied to this prepaid card)
sag_logs = execute_query("""
has_tm_manual = _column_exists("tmodule_times", "manual_rounded_hours")
has_tm_extra = _column_exists("tmodule_times", "extra_billed_hours")
has_tm_multiplier = _column_exists("tmodule_times", "rate_multiplier")
has_w_manual = _column_exists("tticket_worklog", "manual_rounded_hours")
has_w_extra = _column_exists("tticket_worklog", "extra_billed_hours")
has_w_multiplier = _column_exists("tticket_worklog", "rate_multiplier")
tm_manual_select = "tm.manual_rounded_hours" if has_tm_manual else "NULL AS manual_rounded_hours"
tm_extra_select = "tm.extra_billed_hours" if has_tm_extra else "0 AS extra_billed_hours"
tm_multiplier_select = "tm.rate_multiplier" if has_tm_multiplier else "1.0 AS rate_multiplier"
w_manual_select = "w.manual_rounded_hours" if has_w_manual else "NULL AS manual_rounded_hours"
w_extra_select = "w.extra_billed_hours" if has_w_extra else "0 AS extra_billed_hours"
w_multiplier_select = "w.rate_multiplier" if has_w_multiplier else "1.0 AS rate_multiplier"
sag_logs = execute_query(f"""
SELECT
tm.id,
tm.sag_id,
@ -182,6 +216,9 @@ async def get_prepaid_card(card_id: int):
tm.description,
tm.original_hours,
tm.approved_hours,
{tm_manual_select},
{tm_extra_select},
{tm_multiplier_select},
tm.created_at,
tm.user_name,
s.titel AS sag_title
@ -190,7 +227,7 @@ async def get_prepaid_card(card_id: int):
WHERE tm.prepaid_card_id = %s
""", (card_id,))
ticket_logs = execute_query("""
ticket_logs = execute_query(f"""
SELECT
w.id,
w.ticket_id,
@ -198,6 +235,9 @@ async def get_prepaid_card(card_id: int):
w.description,
w.hours,
w.rounded_hours,
{w_manual_select},
{w_extra_select},
{w_multiplier_select},
w.created_at,
t.subject AS ticket_title,
t.ticket_number
@ -209,10 +249,17 @@ async def get_prepaid_card(card_id: int):
timelogs = []
for log in sag_logs or []:
actual_hours = float(log['original_hours'])
# Use stored approved_hours if available, else calculate
rounded_hours = float(log.get('approved_hours') or 0) if log.get('approved_hours') else _apply_rounding(actual_hours, rounding_minutes)
# Priority: manual override -> approved_hours -> auto rounding
rounded_hours = (
float(log.get('manual_rounded_hours') or 0) if log.get('manual_rounded_hours')
else float(log.get('approved_hours') or 0) if log.get('approved_hours')
else _apply_rounding(actual_hours, rounding_minutes)
)
timelogs.append({
"source": "sag",
"entry_id": log.get("id"),
"id": log.get("id"),
"time_id": log.get("id"),
"source_id": log.get("sag_id"),
"source_title": log.get("sag_title"),
"worked_date": log.get("worked_date"),
@ -220,16 +267,25 @@ async def get_prepaid_card(card_id: int):
"description": log.get("description"),
"user_name": log.get("user_name"),
"actual_hours": actual_hours,
"rounded_hours": rounded_hours
"rounded_hours": rounded_hours,
"extra_billed_hours": float(log.get("extra_billed_hours") or 0),
"rate_multiplier": float(log.get("rate_multiplier") or 1.0)
})
for log in ticket_logs or []:
actual_hours = float(log['hours'])
# Use stored rounded_hours if available, else use actual
rounded_hours = float(log.get('rounded_hours') or 0) if log.get('rounded_hours') else actual_hours
# Priority: manual override -> rounded_hours -> actual
rounded_hours = (
float(log.get('manual_rounded_hours') or 0) if log.get('manual_rounded_hours')
else float(log.get('rounded_hours') or 0) if log.get('rounded_hours')
else actual_hours
)
ticket_label = log.get("ticket_number") or log.get("ticket_id")
timelogs.append({
"source": "ticket",
"entry_id": log.get("id"),
"id": log.get("id"),
"worklog_id": log.get("id"),
"source_id": log.get("ticket_id"),
"source_title": log.get("ticket_title") or "Ticket",
"ticket_number": ticket_label,
@ -238,7 +294,9 @@ async def get_prepaid_card(card_id: int):
"description": log.get("description"),
"user_name": None,
"actual_hours": actual_hours,
"rounded_hours": rounded_hours
"rounded_hours": rounded_hours,
"extra_billed_hours": float(log.get("extra_billed_hours") or 0),
"rate_multiplier": float(log.get("rate_multiplier") or 1.0)
})
timelogs.sort(
@ -322,12 +380,13 @@ async def create_prepaid_card(card: PrepaidCardCreate):
@router.put("/prepaid-cards/{card_id:int}/status")
async def update_card_status(card_id: int, status: str):
async def update_card_status(card_id: int, payload: PrepaidCardStatusUpdate):
"""
Update prepaid card status (cancel, reactivate)
Update prepaid card status
"""
try:
if status not in ['active', 'cancelled']:
status = str(payload.status or "").strip().lower()
if status not in ['active', 'cancelled', 'closed']:
raise HTTPException(status_code=400, detail="Invalid status")
conn = None
@ -372,6 +431,15 @@ async def update_card_rounding(card_id: int, payload: PrepaidCardRoundingUpdate)
if rounding_minutes not in (0, 15, 30, 60):
raise HTTPException(status_code=400, detail="Invalid rounding minutes")
current = execute_query(
"SELECT status FROM tticket_prepaid_cards WHERE id = %s",
(card_id,)
)
if not current:
raise HTTPException(status_code=404, detail="Card not found")
if str(current[0].get("status") or "").lower() == "closed":
raise HTTPException(status_code=409, detail="Closed prepaid cards cannot be modified")
result = execute_query(
"""
UPDATE tticket_prepaid_cards

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Request
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import logging
@ -22,13 +22,18 @@ async def prepaid_cards_page(request: Request):
@router.get("/prepaid-cards/{card_id}", response_class=HTMLResponse)
async def prepaid_card_detail(request: Request, card_id: int):
async def prepaid_card_detail(request: Request, card_id: str):
"""
Prepaid card detail page
"""
logger.info(f"🔍 Rendering prepaid card detail: {card_id}")
try:
card_id_int = int(card_id)
except (TypeError, ValueError):
raise HTTPException(status_code=404, detail="Prepaid card not found")
logger.info(f"🔍 Rendering prepaid card detail: {card_id_int}")
return templates.TemplateResponse("detail.html", {
"request": request,
"page_title": "Card Details",
"card_id": card_id
"card_id": card_id_int
})

View File

@ -105,13 +105,16 @@
<th>Dato</th>
<th>Sag / Ticket</th>
<th>Beskrivelse</th>
<th class="text-end">Multiplier</th>
<th>Tekst</th>
<th class="text-end">Faktisk tid</th>
<th class="text-end">Afrundet</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody id="timelogsBody">
<tr>
<td colspan="5" class="text-center py-5">
<td colspan="8" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
@ -120,9 +123,10 @@
</tbody>
<tfoot class="table-light">
<tr>
<td colspan="3" class="text-end fw-bold">I alt:</td>
<td colspan="5" class="text-end fw-bold">I alt:</td>
<td class="text-end fw-bold" id="totalActualHours">-</td>
<td class="text-end fw-bold" id="totalRoundedHours">-</td>
<td></td>
</tr>
</tfoot>
</table>
@ -131,14 +135,184 @@
</div>
</div>
<div class="modal fade" id="editTimelogModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Rediger tidsregistrering</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Faktisk tid (timer)</label>
<input type="number" class="form-control" id="editActualHours" min="0.01" step="0.01">
</div>
<div class="mb-3">
<label class="form-label">Fakturerbar/Afrundet tid (timer)</label>
<input type="number" class="form-control" id="editRoundedHours" min="0.00" step="0.01" readonly>
</div>
<div class="mb-3" id="editExtraWrap">
<label class="form-label">Ekstra tid (timer)</label>
<input type="number" class="form-control" id="editExtraHours" min="0.00" step="0.01" value="0">
<div class="form-text">Kun relevant for ticket-registreringer.</div>
</div>
<div class="mb-3">
<label class="form-label">Multiplier preset</label>
<select class="form-select" id="editMultiplierPreset" onchange="applyEditMultiplierPreset()">
<option value="">Ingen (x1.00)</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Tekst/Beskrivelse</label>
<textarea class="form-control" id="editDescription" rows="3" placeholder="Tekst til tidsregistreringen"></textarea>
</div>
<div class="mb-0" id="editReasonWrap">
<label class="form-label">Begrundelse</label>
<input type="text" class="form-control" id="editReason" maxlength="255" placeholder="Fx aftalt minimumsforbrug">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" onclick="saveTimelogEdit()">Gem ændringer</button>
</div>
</div>
</div>
</div>
<script>
const cardId = {{ card_id }};
let currentTimelogs = [];
let currentEditTimelog = null;
let editTimelogModal;
let timeMultiplierPresets = [];
let currentCardRoundingMinutes = 0;
let currentCardStatus = '';
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 },
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
];
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Load card details
document.addEventListener('DOMContentLoaded', () => {
loadCardDetails();
editTimelogModal = new bootstrap.Modal(document.getElementById('editTimelogModal'));
loadTimeMultiplierPresets().then(() => {
renderEditMultiplierPresetOptions();
loadCardDetails();
});
});
function normalizeMultiplierPresets(raw) {
if (!Array.isArray(raw)) return [];
const normalized = [];
for (const row of raw) {
if (!row || typeof row !== 'object') continue;
const label = String(row.label || row.name || '').trim();
const text = String(row.text || row.description || label || '').trim();
const multiplier = Number(row.multiplier ?? row.value ?? 1);
if (!label || !Number.isFinite(multiplier) || multiplier <= 0) continue;
normalized.push({
label,
text,
multiplier: Number(multiplier.toFixed(2))
});
}
return normalized;
}
async function loadTimeMultiplierPresets() {
try {
const response = await fetch('/api/v1/settings/time_multiplier_presets');
if (!response.ok) {
timeMultiplierPresets = [...DEFAULT_TIME_MULTIPLIER_PRESETS];
return;
}
const setting = await response.json();
const parsed = JSON.parse(setting.value || '[]');
const normalized = normalizeMultiplierPresets(parsed);
timeMultiplierPresets = normalized.length ? normalized : [...DEFAULT_TIME_MULTIPLIER_PRESETS];
} catch (error) {
console.warn('Kunne ikke hente multiplier presets', error);
timeMultiplierPresets = [...DEFAULT_TIME_MULTIPLIER_PRESETS];
}
}
function renderEditMultiplierPresetOptions() {
const select = document.getElementById('editMultiplierPreset');
if (!select) return;
const options = timeMultiplierPresets.map((preset, idx) => {
const value = Number(preset.multiplier).toFixed(2);
return `<option value="${idx}">${escapeHtml(preset.label)} (x${value})</option>`;
}).join('');
select.innerHTML = `<option value="">Ingen (x1.00)</option>${options}`;
}
function applyEditMultiplierPreset() {
const select = document.getElementById('editMultiplierPreset');
const descEl = document.getElementById('editDescription');
if (!select || !descEl) return;
if (select.value !== '') {
const preset = timeMultiplierPresets[Number(select.value)];
if (preset && preset.text && !(descEl.value || '').trim()) {
descEl.value = preset.text;
}
}
recalcRoundedFromInputs();
}
function findClosestPresetIndex(multiplierValue) {
const numeric = Number(multiplierValue);
if (!Number.isFinite(numeric) || numeric <= 0 || !timeMultiplierPresets.length) return '';
const idx = timeMultiplierPresets.findIndex((preset) => Math.abs(Number(preset.multiplier) - numeric) < 0.001);
return idx >= 0 ? String(idx) : '';
}
function getMultiplierText(multiplierValue) {
const numeric = Number(multiplierValue);
if (!Number.isFinite(numeric) || numeric <= 0 || !timeMultiplierPresets.length) return '';
const preset = timeMultiplierPresets.find((item) => Math.abs(Number(item.multiplier) - numeric) < 0.001);
if (!preset) return '';
return preset.text || preset.label || '';
}
function getSelectedEditMultiplier() {
const presetIdx = document.getElementById('editMultiplierPreset')?.value;
const selectedPreset = presetIdx !== '' && presetIdx != null ? timeMultiplierPresets[Number(presetIdx)] : null;
const selected = selectedPreset ? Number(selectedPreset.multiplier) : 1.0;
return Number.isFinite(selected) && selected > 0 ? selected : 1.0;
}
function roundUpHoursByMinutes(hours, roundingMinutes) {
const value = Number(hours);
if (!Number.isFinite(value) || value <= 0) return 0;
const stepMinutes = Number(roundingMinutes) || 0;
if (stepMinutes <= 0) return value;
const stepHours = stepMinutes / 60;
return Math.ceil(value / stepHours) * stepHours;
}
function recalcRoundedFromInputs() {
const actualInput = document.getElementById('editActualHours');
const roundedInput = document.getElementById('editRoundedHours');
if (!actualInput || !roundedInput) return;
const actualHours = parseFloat(actualInput.value || '0');
const multiplier = getSelectedEditMultiplier();
const roundedBase = roundUpHoursByMinutes(actualHours, currentCardRoundingMinutes);
const computed = roundedBase * multiplier;
roundedInput.value = Number.isFinite(computed) && computed > 0 ? computed.toFixed(2) : '0.00';
}
async function loadCardDetails() {
try {
const response = await fetch(`/api/v1/prepaid-cards/${cardId}`);
@ -148,6 +322,12 @@ async function loadCardDetails() {
}
const card = await response.json();
currentCardStatus = String(card.status || '').toLowerCase();
const customerName = escapeHtml(card.customer_name || '-');
const customerEmail = escapeHtml(card.customer_email || '');
const cardNumber = escapeHtml(card.card_number || '-');
const notesHtml = card.notes ? escapeHtml(card.notes) : '';
const invoiceNo = card.economic_invoice_number ? escapeHtml(card.economic_invoice_number) : '';
// Update header
document.getElementById('cardNumber').textContent = card.card_number;
@ -199,7 +379,7 @@ async function loadCardDetails() {
document.getElementById('cardInfo').innerHTML = `
<div class="col-md-6">
<label class="small text-muted">Kortnummer</label>
<p class="mb-0"><strong>${card.card_number}</strong></p>
<p class="mb-0"><strong>${cardNumber}</strong></p>
</div>
<div class="col-md-6">
<label class="small text-muted">Status</label>
@ -209,10 +389,10 @@ async function loadCardDetails() {
<label class="small text-muted">Kunde</label>
<p class="mb-0">
<a href="/customers/${card.customer_id}" class="text-decoration-none">
${card.customer_name || '-'}
${customerName}
</a>
</p>
<small class="text-muted">${card.customer_email || ''}</small>
<small class="text-muted">${customerEmail}</small>
</div>
<div class="col-md-6">
<label class="small text-muted">Pris pr. Time</label>
@ -241,13 +421,13 @@ async function loadCardDetails() {
${card.economic_invoice_number ? `
<div class="col-md-6">
<label class="small text-muted">e-conomic Fakturanr.</label>
<p class="mb-0">${card.economic_invoice_number}</p>
<p class="mb-0">${invoiceNo}</p>
</div>
` : ''}
${card.notes ? `
<div class="col-12">
<label class="small text-muted">Bemærkninger</label>
<p class="mb-0">${card.notes}</p>
<p class="mb-0">${notesHtml}</p>
</div>
` : ''}
`;
@ -255,11 +435,27 @@ async function loadCardDetails() {
// Update action buttons
const actions = [];
if (card.status === 'active') {
actions.push(`
<button class="btn btn-secondary w-100 mb-2" onclick="closeCard()">
<i class="bi bi-lock"></i> Luk Kort
</button>
`);
actions.push(`
<button class="btn btn-warning w-100 mb-2" onclick="cancelCard()">
<i class="bi bi-x-circle"></i> Annuller Kort
</button>
`);
} else if (card.status === 'closed') {
actions.push(`
<button class="btn btn-success w-100 mb-2" onclick="reopenCard()">
<i class="bi bi-unlock"></i> Genåbn Kort
</button>
`);
actions.push(`
<div class="alert alert-secondary py-2 mb-0 small">
Kortet er lukket. Tid og afrunding kan ikke redigeres.
</div>
`);
}
document.getElementById('actionButtons').innerHTML = actions.join('') ||
'<p class="text-muted text-center mb-0">Ingen handlinger tilgængelige</p>';
@ -267,7 +463,12 @@ async function loadCardDetails() {
const roundingSelect = document.getElementById('roundingMinutes');
if (roundingSelect) {
roundingSelect.value = String(card.rounding_minutes || 0);
const isClosed = currentCardStatus === 'closed';
roundingSelect.disabled = isClosed;
const roundingButton = roundingSelect.parentElement?.querySelector('button');
if (roundingButton) roundingButton.disabled = isClosed;
}
currentCardRoundingMinutes = parseInt(card.rounding_minutes || 0, 10) || 0;
// Render timelogs
renderTimelogs(card.timelogs || []);
@ -277,7 +478,7 @@ async function loadCardDetails() {
document.getElementById('cardInfo').innerHTML = `
<div class="col-12 text-center text-danger py-5">
<i class="bi bi-exclamation-circle fs-1 mb-3"></i>
<p>❌ Fejl ved indlæsning: ${error.message}</p>
<p>❌ Fejl ved indlæsning: ${escapeHtml(error.message)}</p>
<button class="btn btn-primary" onclick="window.location.href='/prepaid-cards'">
Tilbage til oversigt
</button>
@ -288,10 +489,11 @@ async function loadCardDetails() {
function renderTimelogs(timelogs) {
const tbody = document.getElementById('timelogsBody');
currentTimelogs = timelogs || [];
if (!timelogs || timelogs.length === 0) {
tbody.innerHTML = `
<tr><td colspan="5" class="text-center text-muted py-5">
<tr><td colspan="8" class="text-center text-muted py-5">
Ingen timelogs endnu
</td></tr>
`;
@ -303,29 +505,41 @@ function renderTimelogs(timelogs) {
let totalActual = 0;
let totalRounded = 0;
tbody.innerHTML = timelogs.map(t => {
tbody.innerHTML = timelogs.map((t, index) => {
const dateValue = t.worked_date || t.created_at;
const dateText = dateValue ? new Date(dateValue).toLocaleDateString('da-DK') : '-';
const sourceTitle = escapeHtml(t.source_title || '');
const description = escapeHtml(t.description || '-');
let sourceHtml = '-';
if (t.source === 'sag' && t.source_id) {
sourceHtml = `<a href="/sag/${t.source_id}/v3" class="text-decoration-none">Sag #${t.source_id}${t.source_title ? ' - ' + t.source_title : ''}</a>`;
sourceHtml = `<a href="/sag/${t.source_id}/v3" class="text-decoration-none">Sag #${t.source_id}${t.source_title ? ' - ' + sourceTitle : ''}</a>`;
} else if (t.source === 'ticket' && t.source_id) {
const ticketLabel = t.ticket_number ? `#${t.ticket_number}` : `#${t.source_id}`;
sourceHtml = `<a href="/ticket/tickets/${t.source_id}" class="text-decoration-none">${ticketLabel} - ${t.source_title || 'Ticket'}</a>`;
sourceHtml = `<a href="/ticket/tickets/${t.source_id}" class="text-decoration-none">${escapeHtml(ticketLabel)} - ${sourceTitle || 'Ticket'}</a>`;
}
const actual = parseFloat(t.actual_hours) || 0;
const rounded = parseFloat(t.rounded_hours) || 0;
const multiplier = parseFloat(t.rate_multiplier) || 1;
const multiplierText = escapeHtml(getMultiplierText(multiplier) || '-');
totalActual += actual;
totalRounded += rounded;
const resolvedEntryId = t.entry_id || t.id || t.time_id || t.worklog_id || null;
const canEdit = !!resolvedEntryId && currentCardStatus !== 'closed';
const actionHtml = canEdit
? `<button class="btn btn-sm btn-outline-primary" onclick="openTimelogEditModal(${index})"><i class="bi bi-pencil"></i> Rediger</button>`
: (currentCardStatus === 'closed' ? '<span class="text-muted">Låst</span>' : '<span class="text-muted">-</span>');
return `
<tr>
<td>${dateText}</td>
<td>${sourceHtml}</td>
<td>${t.description || '-'}</td>
<td>${description}</td>
<td class="text-end">x${multiplier.toFixed(2)}</td>
<td>${multiplierText}</td>
<td class="text-end">${actual.toFixed(2)} t</td>
<td class="text-end">${rounded.toFixed(2)} t</td>
<td class="text-end">${actionHtml}</td>
</tr>
`;
}).join('');
@ -335,9 +549,111 @@ function renderTimelogs(timelogs) {
document.getElementById('totalRoundedHours').textContent = totalRounded.toFixed(2) + ' t';
}
function openTimelogEditModal(index) {
if (currentCardStatus === 'closed') {
alert('Kortet er lukket og kan ikke redigeres');
return;
}
const timelog = currentTimelogs[index];
if (!timelog) return;
const resolvedEntryId = timelog.entry_id || timelog.id || timelog.time_id || timelog.worklog_id || null;
if (!resolvedEntryId) return;
timelog._resolved_entry_id = resolvedEntryId;
currentEditTimelog = timelog;
document.getElementById('editActualHours').value = (parseFloat(timelog.actual_hours) || 0).toFixed(2);
document.getElementById('editRoundedHours').value = (parseFloat(timelog.rounded_hours) || 0).toFixed(2);
document.getElementById('editExtraHours').value = (parseFloat(timelog.extra_billed_hours) || 0).toFixed(2);
document.getElementById('editDescription').value = timelog.description || '';
document.getElementById('editReason').value = '';
document.getElementById('editMultiplierPreset').value = findClosestPresetIndex(timelog.rate_multiplier || 1.0);
const isTicket = timelog.source === 'ticket';
document.getElementById('editExtraWrap').classList.toggle('d-none', !isTicket);
document.getElementById('editReasonWrap').classList.toggle('d-none', !isTicket);
document.getElementById('editActualHours').oninput = recalcRoundedFromInputs;
recalcRoundedFromInputs();
editTimelogModal.show();
}
async function saveTimelogEdit() {
if (!currentEditTimelog) return;
const resolvedEntryId = currentEditTimelog._resolved_entry_id || currentEditTimelog.entry_id || currentEditTimelog.id || currentEditTimelog.time_id || currentEditTimelog.worklog_id;
if (!resolvedEntryId) return;
const actualHours = parseFloat(document.getElementById('editActualHours').value || '0');
const multiplier = getSelectedEditMultiplier();
const roundedBase = roundUpHoursByMinutes(actualHours, currentCardRoundingMinutes);
const roundedHours = roundedBase * multiplier;
const extraHours = parseFloat(document.getElementById('editExtraHours').value || '0');
const description = (document.getElementById('editDescription').value || '').trim();
const reason = (document.getElementById('editReason').value || '').trim();
const presetIdx = document.getElementById('editMultiplierPreset').value;
const selectedPreset = presetIdx !== '' ? timeMultiplierPresets[Number(presetIdx)] : null;
const rateMultiplier = selectedPreset ? Number(selectedPreset.multiplier) : multiplier;
const finalDescription = description || (selectedPreset ? selectedPreset.text : (currentEditTimelog.description || ''));
if (!actualHours || actualHours <= 0) {
alert('Faktisk tid skal være større end 0');
return;
}
if (extraHours < 0) {
alert('Ekstra tid kan ikke være negativ');
return;
}
try {
if (currentEditTimelog.source === 'ticket') {
const payload = {
hours: actualHours,
manual_rounded_hours: roundedHours,
extra_billed_hours: extraHours,
rate_multiplier: Number.isFinite(rateMultiplier) && rateMultiplier > 0 ? rateMultiplier : 1.0,
description: finalDescription,
rounding_override_reason: reason || 'Redigeret fra prepaid kort detaljeside'
};
const response = await fetch(`/api/v1/worklog/${resolvedEntryId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Kunne ikke opdatere ticket tidslinje');
}
} else {
const payload = {
faktisk_tid_min: Math.round(actualHours * 60),
fakturerbar_tid_min: Math.round(roundedHours * 60),
description: finalDescription,
rate_multiplier: Number.isFinite(rateMultiplier) && rateMultiplier > 0 ? rateMultiplier : 1.0
};
const response = await fetch(`/api/v1/timetracking/time/${resolvedEntryId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Kunne ikke opdatere sag tidslinje');
}
}
editTimelogModal.hide();
await loadCardDetails();
alert('✅ Tidslinje opdateret');
} catch (error) {
console.error('Error updating timelog:', error);
alert('❌ Fejl: ' + (error.message || 'Ukendt fejl'));
}
}
function getStatusBadge(status) {
const badges = {
'active': '<span class="badge bg-success">Aktiv</span>',
'closed': '<span class="badge bg-dark">Lukket</span>',
'depleted': '<span class="badge bg-secondary">Opbrugt</span>',
'expired': '<span class="badge bg-danger">Udløbet</span>',
'cancelled': '<span class="badge bg-warning">Annulleret</span>'
@ -345,30 +661,50 @@ function getStatusBadge(status) {
return badges[status] || status;
}
async function cancelCard() {
if (!confirm('Er du sikker på at du vil annullere dette kort?')) {
return;
}
async function updateCardStatus(nextStatus, successMessage) {
try {
const response = await fetch(`/api/v1/prepaid-cards/${cardId}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'cancelled' })
body: JSON.stringify({ status: nextStatus })
});
if (!response.ok) throw new Error('Fejl ved annullering');
alert('✅ Kort annulleret');
loadCardDetails(); // Reload
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Fejl ved statusændring');
}
alert(successMessage);
loadCardDetails();
} catch (error) {
console.error('Error cancelling card:', error);
alert('❌ Fejl: ' + error.message);
console.error('Error updating status:', error);
alert('❌ Fejl: ' + (error.message || 'Ukendt fejl'));
}
}
async function closeCard() {
if (!confirm('Er du sikker på at du vil lukke dette kort?')) return;
await updateCardStatus('closed', '✅ Kort lukket');
}
async function reopenCard() {
if (!confirm('Vil du genåbne kortet?')) return;
await updateCardStatus('active', '✅ Kort genåbnet');
}
async function cancelCard() {
if (!confirm('Er du sikker på at du vil annullere dette kort?')) {
return;
}
await updateCardStatus('cancelled', '✅ Kort annulleret');
}
async function saveRounding() {
if (currentCardStatus === 'closed') {
alert('Kortet er lukket og afrunding kan ikke ændres');
return;
}
const select = document.getElementById('roundingMinutes');
if (!select) return;
const roundingMinutes = parseInt(select.value, 10) || 0;
@ -382,6 +718,7 @@ async function saveRounding() {
if (!response.ok) throw new Error('Fejl ved opdatering');
currentCardRoundingMinutes = roundingMinutes;
alert('✅ Afrunding opdateret');
loadCardDetails();
} catch (error) {

View File

@ -98,6 +98,7 @@
<select class="form-select" id="statusFilter" onchange="loadCards()">
<option value="">Alle</option>
<option value="active">Aktive</option>
<option value="closed">Lukket</option>
<option value="depleted">Opbrugt</option>
<option value="expired">Udløbet</option>
<option value="cancelled">Annulleret</option>
@ -254,6 +255,15 @@
<script>
let createCardModal;
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
createCardModal = new bootstrap.Modal(document.getElementById('createCardModal'));
@ -307,8 +317,8 @@ async function loadCards() {
} catch (error) {
console.error('Error loading cards:', error);
document.getElementById('cardsTableBody').innerHTML = `
<tr><td colspan="10" class="text-center text-danger">
❌ Fejl ved indlæsning: ${error.message}
<tr><td colspan="11" class="text-center text-danger">
❌ Fejl ved indlæsning: ${escapeHtml(error.message)}
</td></tr>
`;
}
@ -328,6 +338,9 @@ function renderCards(cards) {
}
tbody.innerHTML = cards.map(card => {
const cardNumber = escapeHtml(card.card_number || '-');
const customerName = escapeHtml(card.customer_name || '-');
const customerEmail = escapeHtml(card.customer_email || '');
const statusBadge = getStatusBadge(card.status);
const expiresAt = card.expires_at ?
new Date(card.expires_at).toLocaleDateString('da-DK') : '-';
@ -351,12 +364,12 @@ function renderCards(cards) {
<tr>
<td>
<a href="/prepaid-cards/${card.id}" class="text-decoration-none">
<strong>${card.card_number}</strong>
<strong>${cardNumber}</strong>
</a>
</td>
<td>
<div>${card.customer_name || '-'}</div>
<small class="text-muted">${card.customer_email || ''}</small>
<div>${customerName}</div>
<small class="text-muted">${customerEmail}</small>
</td>
<td class="text-end">${purchasedHours.toFixed(1)} t</td>
<td class="text-end">${usedHours.toFixed(1)} t</td>
@ -405,6 +418,7 @@ function renderCards(cards) {
function getStatusBadge(status) {
const badges = {
'active': '<span class="badge bg-success">Aktiv</span>',
'closed': '<span class="badge bg-dark">Lukket</span>',
'depleted': '<span class="badge bg-secondary">Opbrugt</span>',
'expired': '<span class="badge bg-danger">Udløbet</span>',
'cancelled': '<span class="badge bg-warning">Annulleret</span>'
@ -466,9 +480,9 @@ function renderCustomerDropdown(customers) {
}
list.innerHTML = customers.map(c => `
<a href="#" class="dropdown-item py-2 border-bottom" onclick="selectCustomer(${c.id}, '${c.name.replace(/'/g, "\\'")}')">
<div class="fw-bold">${c.name}</div>
${c.email ? `<small class="text-muted">${c.email}</small>` : ''}
<a href="#" class="dropdown-item py-2 border-bottom" onclick='selectCustomer(${Number(c.id)}, ${JSON.stringify(c.name || '')})'>
<div class="fw-bold">${escapeHtml(c.name || '')}</div>
${c.email ? `<small class="text-muted">${escapeHtml(c.email)}</small>` : ''}
</a>
`).join('');
}
@ -477,7 +491,7 @@ function selectCustomer(id, name) {
document.getElementById('customerId').value = id;
const btn = document.getElementById('customerDropdownBtn');
btn.innerHTML = `
<span class="fw-bold text-dark">${name}</span>
<span class="fw-bold text-dark">${escapeHtml(name)}</span>
<i class="bi bi-chevron-down small"></i>
`;
btn.classList.add('border-primary'); // Highlight selection

View File

@ -285,6 +285,27 @@ async def update_setting(key: str, setting: SettingUpdate):
(key, setting.value, category, description, value_type, is_public),
)
if not result and key == "time_multiplier_presets":
result = execute_query(
"""
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (key)
DO UPDATE SET
value = EXCLUDED.value,
updated_at = CURRENT_TIMESTAMP
RETURNING *
""",
(
"time_multiplier_presets",
setting.value,
"system",
"Valgbare multiplikator presets til tidsregistrering",
"json",
True,
),
)
if not result:
raise HTTPException(status_code=404, detail="Setting not found")

View File

@ -1537,6 +1537,54 @@ async def scan_document(file_path: str):
</div>
</div>
</div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1 fw-bold">Tidsregistrering Multipliers</h5>
<p class="text-muted mb-0">Opret valgbare presets med tekst + multiplikator til tidsregistrering.</p>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-md-4">
<label class="form-label">Navn</label>
<input type="text" class="form-control" id="timeMultiplierPresetLabel" placeholder="Fx Haster">
</div>
<div class="col-md-4">
<label class="form-label">Tekst</label>
<input type="text" class="form-control" id="timeMultiplierPresetText" placeholder="Fx Haster + avanceret netværk">
</div>
<div class="col-md-2">
<label class="form-label">Multiplier</label>
<input type="number" class="form-control" id="timeMultiplierPresetValue" min="1" step="0.1" value="1">
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-outline-primary w-100" onclick="addTimeMultiplierPreset()">
<i class="bi bi-plus-lg me-1"></i>Tilføj
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Navn</th>
<th>Tekst</th>
<th class="text-center" style="width: 120px;">Multiplier</th>
<th class="text-end" style="width: 110px;">Handling</th>
</tr>
</thead>
<tbody id="timeMultiplierPresetsTableBody">
<tr><td colspan="4" class="text-muted">Indlæser...</td></tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-end mt-3">
<button class="btn btn-primary" onclick="saveTimeMultiplierPresets()">
<i class="bi bi-save me-2"></i>Gem presets
</button>
</div>
</div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
@ -1898,6 +1946,13 @@ let allSettings = [];
let pipelineStagesCache = [];
let nextcloudInstancesCache = [];
let customersCache = [];
let timeMultiplierPresetsCache = [];
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 },
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
];
function getSettingValue(key, fallback = '') {
const found = allSettings.find(s => s.key === key);
@ -2305,6 +2360,7 @@ async function loadSettings() {
const response = await fetch('/api/v1/settings');
allSettings = await response.json();
displaySettingsByCategory();
loadTimeMultiplierPresets();
renderTelefoniSettings();
renderMissionSettings();
await loadCaseTypesSetting();
@ -2318,6 +2374,125 @@ async function loadSettings() {
}
}
function normalizeTimeMultiplierPresets(raw) {
if (!Array.isArray(raw)) return [];
const normalized = [];
for (const row of raw) {
if (!row || typeof row !== 'object') continue;
const label = String(row.label || row.name || '').trim();
const text = String(row.text || row.description || label || '').trim();
const multiplier = Number(row.multiplier ?? row.value ?? 1);
if (!label || !Number.isFinite(multiplier) || multiplier <= 0) continue;
normalized.push({
label,
text,
multiplier: Number(multiplier.toFixed(2))
});
}
return normalized;
}
function renderTimeMultiplierPresets() {
const tbody = document.getElementById('timeMultiplierPresetsTableBody');
if (!tbody) return;
if (!timeMultiplierPresetsCache.length) {
tbody.innerHTML = '<tr><td colspan="4" class="text-muted">Ingen presets oprettet endnu.</td></tr>';
return;
}
tbody.innerHTML = timeMultiplierPresetsCache.map((preset, idx) => `
<tr>
<td>${escapeHtml(preset.label)}</td>
<td>${escapeHtml(preset.text || '')}</td>
<td class="text-center">x${Number(preset.multiplier).toFixed(2)}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" onclick="removeTimeMultiplierPreset(${idx})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
}
function loadTimeMultiplierPresets() {
const setting = allSettings.find(s => s.key === 'time_multiplier_presets');
if (!setting || !setting.value) {
timeMultiplierPresetsCache = [...DEFAULT_TIME_MULTIPLIER_PRESETS];
renderTimeMultiplierPresets();
return;
}
try {
const parsed = JSON.parse(setting.value);
const normalized = normalizeTimeMultiplierPresets(parsed);
timeMultiplierPresetsCache = normalized.length ? normalized : [...DEFAULT_TIME_MULTIPLIER_PRESETS];
} catch (error) {
console.warn('Kunne ikke parse time_multiplier_presets, bruger defaults', error);
timeMultiplierPresetsCache = [...DEFAULT_TIME_MULTIPLIER_PRESETS];
}
renderTimeMultiplierPresets();
}
function addTimeMultiplierPreset() {
const labelInput = document.getElementById('timeMultiplierPresetLabel');
const textInput = document.getElementById('timeMultiplierPresetText');
const multiplierInput = document.getElementById('timeMultiplierPresetValue');
if (!labelInput || !textInput || !multiplierInput) return;
const label = (labelInput.value || '').trim();
const text = (textInput.value || '').trim() || label;
const multiplier = Number(multiplierInput.value || 0);
if (!label) {
showNotification('Navn er paakraevet', 'error');
return;
}
if (!Number.isFinite(multiplier) || multiplier <= 0) {
showNotification('Multiplier skal vaere stoerre end 0', 'error');
return;
}
timeMultiplierPresetsCache.push({
label,
text,
multiplier: Number(multiplier.toFixed(2))
});
renderTimeMultiplierPresets();
labelInput.value = '';
textInput.value = '';
multiplierInput.value = '1';
}
function removeTimeMultiplierPreset(index) {
timeMultiplierPresetsCache = timeMultiplierPresetsCache.filter((_, idx) => idx !== index);
renderTimeMultiplierPresets();
}
async function saveTimeMultiplierPresets() {
const payload = JSON.stringify(timeMultiplierPresetsCache);
try {
const response = await fetch('/api/v1/settings/time_multiplier_presets', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: payload })
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke gemme presets'));
}
setOrAddSettingInCache('time_multiplier_presets', payload);
showNotification('Multiplier presets gemt', 'success');
} catch (error) {
console.error('Failed saving time multiplier presets', error);
showNotification(error.message || 'Kunne ikke gemme presets', 'error');
}
}
function displaySettingsByCategory() {
const categories = {
company: ['company_name', 'company_cvr', 'company_email', 'company_phone', 'company_website', 'company_address'],

View File

@ -236,6 +236,12 @@ class TTicketWorklogBase(BaseModel):
description: Optional[str] = None
billing_method: BillingMethod = Field(default=BillingMethod.INVOICE)
is_internal: bool = Field(default=False, description="Skjul for kunde (vises ikke på faktura/portal)")
extra_billed_hours: Decimal = Field(default=Decimal('0'), ge=0, description="Ekstra timer der faktureres ud over faktisk tid")
extended_support_flag: bool = Field(default=False, description="Markering for udvidet support")
rate_multiplier: Decimal = Field(default=Decimal('1.0'), gt=0, description="Multiplikator for timesats")
manual_hourly_rate: Optional[Decimal] = Field(None, gt=0, description="Manuel timesats for denne registrering")
manual_rounded_hours: Optional[Decimal] = Field(None, gt=0, le=24, description="Manuel override af afrundede timer")
rounding_override_reason: Optional[str] = Field(None, description="Begrundelse for manuel afrundingsoverride")
@field_validator('hours')
@classmethod
@ -265,6 +271,12 @@ class TTicketWorklogUpdate(BaseModel):
status: Optional[WorklogStatus] = None
prepaid_card_id: Optional[int] = None
is_internal: Optional[bool] = None
extra_billed_hours: Optional[Decimal] = Field(None, ge=0)
extended_support_flag: Optional[bool] = None
rate_multiplier: Optional[Decimal] = Field(None, gt=0)
manual_hourly_rate: Optional[Decimal] = Field(None, gt=0)
manual_rounded_hours: Optional[Decimal] = Field(None, gt=0, le=24)
rounding_override_reason: Optional[str] = None
class TTicketWorklog(TTicketWorklogBase):

View File

@ -63,6 +63,8 @@ logger = logging.getLogger(__name__)
router = APIRouter()
sync_admin_access = require_any_permission("users.manage", "system.admin")
LOCKED_PREPAID_STATUSES = {"closed", "cancelled", "depleted", "expired"}
def _get_first_value(data: dict, keys: List[str]) -> Optional[str]:
for key in keys:
@ -120,6 +122,40 @@ def _looks_like_external_id(value: Optional[str]) -> bool:
return bool(re.match(r"^\d+x\d+$", str(value)))
def _assert_prepaid_card_editable(prepaid_card_id: Optional[int]) -> None:
if not prepaid_card_id:
return
card = execute_query_single(
"SELECT status FROM tticket_prepaid_cards WHERE id = %s",
(prepaid_card_id,)
)
if not card:
raise HTTPException(status_code=409, detail="Linked prepaid card not found")
card_status = str(card.get("status") or "").lower()
if card_status in LOCKED_PREPAID_STATUSES:
raise HTTPException(
status_code=409,
detail=f"Time registration is locked because prepaid card is {card_status}"
)
def _compute_billable_hours_for_worklog(worklog: dict) -> float:
if not worklog:
return 0.0
if worklog.get("manual_rounded_hours") is not None:
base_hours = float(worklog.get("manual_rounded_hours") or 0)
elif worklog.get("rounded_hours") is not None:
base_hours = float(worklog.get("rounded_hours") or 0)
else:
base_hours = float(worklog.get("hours") or 0)
extra = float(worklog.get("extra_billed_hours") or 0)
return max(0.0, base_hours + extra)
def _calculate_hash(data: dict) -> str:
payload = json.dumps(data, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
@ -697,12 +733,16 @@ async def create_worklog(
)
else:
rounded_hours = float(worklog_data.hours)
if worklog_data.manual_rounded_hours and not worklog_data.rounding_override_reason:
raise HTTPException(status_code=400, detail="rounding_override_reason is required when manual_rounded_hours is set")
worklog_id = execute_insert(
"""
INSERT INTO tticket_worklog
(ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id, is_internal, rounded_hours)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
(ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id, is_internal, rounded_hours,
extra_billed_hours, extended_support_flag, rate_multiplier, manual_hourly_rate, manual_rounded_hours, rounding_override_reason)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
@ -716,7 +756,13 @@ async def create_worklog(
user_id or worklog_data.user_id,
prepaid_card_id,
worklog_data.is_internal,
rounded_hours
rounded_hours,
worklog_data.extra_billed_hours,
worklog_data.extended_support_flag,
worklog_data.rate_multiplier,
worklog_data.manual_hourly_rate,
worklog_data.manual_rounded_hours,
worklog_data.rounding_override_reason
)
)
@ -766,12 +812,17 @@ async def update_worklog(
if not current:
raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found")
_assert_prepaid_card_editable(current.get("prepaid_card_id"))
# Build update query
updates = []
params = []
update_dict = update_data.model_dump(exclude_unset=True)
if "manual_rounded_hours" in update_dict and update_dict.get("manual_rounded_hours") and not update_dict.get("rounding_override_reason"):
raise HTTPException(status_code=400, detail="rounding_override_reason is required when manual_rounded_hours is set")
# Handle prepaid card selection/validation if billing_method is being set to prepaid_card
if 'billing_method' in update_dict and update_dict['billing_method'] == 'prepaid_card':
@ -809,6 +860,26 @@ async def update_worklog(
raise HTTPException(
status_code=400,
detail="Valgt klippekort er ikke aktivt eller tilhører ikke kunden")
effective_billing_method = update_dict.get('billing_method', current.get('billing_method'))
effective_prepaid_card_id = update_dict.get('prepaid_card_id', current.get('prepaid_card_id'))
effective_hours = float(update_dict.get('hours', current.get('hours') or 0))
if effective_billing_method in ['prepaid_card', 'prepaid'] and effective_prepaid_card_id:
card = execute_query_single(
"SELECT rounding_minutes FROM tticket_prepaid_cards WHERE id = %s",
(effective_prepaid_card_id,)
)
if card and 'manual_rounded_hours' not in update_dict:
rounding_minutes = int(card.get('rounding_minutes') or 0)
if rounding_minutes > 0:
from decimal import Decimal, ROUND_CEILING
interval = Decimal(rounding_minutes) / Decimal(60)
update_dict['rounded_hours'] = float(
(Decimal(str(effective_hours)) / interval).to_integral_value(rounding=ROUND_CEILING) * interval
)
else:
update_dict['rounded_hours'] = effective_hours
for field, value in update_dict.items():
if hasattr(value, 'value'):
@ -873,7 +944,7 @@ async def review_worklog(
query += " ORDER BY w.work_date DESC, t.customer_id"
worklogs = execute_query_single(query, tuple(params))
worklogs = execute_query(query, tuple(params))
# Calculate totals
total_hours = Decimal('0')
@ -881,8 +952,8 @@ async def review_worklog(
for w in worklogs or []:
total_hours += Decimal(str(w['hours']))
if w['status'] in ['draft', 'billable']:
total_billable_hours += Decimal(str(w['hours']))
if w['status'] in ['draft', 'billable'] and w.get('billing_method') not in ['prepaid_card', 'prepaid']:
total_billable_hours += Decimal(str(_compute_billable_hours_for_worklog(w)))
return WorklogReviewResponse(
worklogs=worklogs or [],
@ -911,7 +982,7 @@ async def mark_worklog_billable(
for worklog_id in request.worklog_ids:
# Get worklog
worklog = execute_query(
worklog = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,))
@ -922,6 +993,10 @@ async def mark_worklog_billable(
if worklog['status'] != 'draft':
logger.warning(f"⚠️ Worklog {worklog_id} not in draft status, skipping")
continue
if worklog.get('billing_method') in ['prepaid_card', 'prepaid']:
logger.warning(f"⚠️ Worklog {worklog_id} is prepaid and cannot be marked billable")
continue
# Update to billable
execute_update(

View File

@ -624,8 +624,51 @@
// WORKLOG MANAGEMENT
// ============================================
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 },
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
];
function normalizeMultiplierPresets(raw) {
if (!Array.isArray(raw)) return [];
const normalized = [];
for (const row of raw) {
if (!row || typeof row !== 'object') continue;
const label = String(row.label || row.name || '').trim();
const text = String(row.text || row.description || label || '').trim();
const multiplier = Number(row.multiplier ?? row.value ?? 1);
if (!label || !Number.isFinite(multiplier) || multiplier <= 0) continue;
normalized.push({
label,
text,
multiplier: Number(multiplier.toFixed(2))
});
}
return normalized;
}
async function loadTimeMultiplierPresets() {
try {
const response = await fetch('/api/v1/settings/time_multiplier_presets');
if (!response.ok) return [...DEFAULT_TIME_MULTIPLIER_PRESETS];
const setting = await response.json();
const parsed = JSON.parse(setting.value || '[]');
const normalized = normalizeMultiplierPresets(parsed);
return normalized.length ? normalized : [...DEFAULT_TIME_MULTIPLIER_PRESETS];
} catch (error) {
console.warn('Kunne ikke hente multiplier presets', error);
return [...DEFAULT_TIME_MULTIPLIER_PRESETS];
}
}
async function showWorklogModal() {
const today = new Date().toISOString().split('T')[0];
const multiplierPresets = await loadTimeMultiplierPresets();
const multiplierOptions = multiplierPresets.map((preset, idx) => {
const value = Number(preset.multiplier).toFixed(2);
return `<option value="${idx}">${preset.label} (x${value})</option>`;
}).join('');
// Fetch Prepaid Cards for this customer
let prepaidOptions = '';
@ -651,6 +694,7 @@
// Store for use in submitWorklog
window._activePrepaidCards = activePrepaidCards;
window._timeMultiplierPresets = multiplierPresets;
const modalHtml = `
<div class="modal fade" id="worklogModal" tabindex="-1">
@ -700,6 +744,14 @@
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="worklogDesc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
</div>
<div class="col-12">
<label class="form-label">Multiplier preset</label>
<select class="form-select" id="worklogMultiplierPreset" onchange="applyWorklogMultiplierPreset()">
<option value="">Ingen (x1.00)</option>
${multiplierOptions}
</select>
<div class="form-text">Vælger du et preset, sendes multiplier på tidslinjen.</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="worklogInternal">
@ -743,6 +795,18 @@
setTimeout(() => document.getElementById('worklogHours').focus(), 500);
}
function applyWorklogMultiplierPreset() {
const select = document.getElementById('worklogMultiplierPreset');
const descEl = document.getElementById('worklogDesc');
if (!select || !descEl) return;
if (select.value === '') return;
const preset = (window._timeMultiplierPresets || [])[Number(select.value)];
if (!preset || !preset.text) return;
if ((descEl.value || '').trim()) return;
descEl.value = preset.text;
}
async function submitWorklog() {
const date = document.getElementById('worklogDate').value;
// Calculate hours from split fields
@ -754,6 +818,10 @@
let billing = document.getElementById('worklogBilling').value;
const desc = document.getElementById('worklogDesc').value;
const isInternal = document.getElementById('worklogInternal').checked;
const presetSelect = document.getElementById('worklogMultiplierPreset');
const selectedPreset = presetSelect && presetSelect.value !== ''
? (window._timeMultiplierPresets || [])[Number(presetSelect.value)]
: null;
let prepaidCardId = null;
@ -782,9 +850,10 @@
hours: hours,
work_type: type,
billing_method: billing,
description: desc,
description: (desc || '').trim() || (selectedPreset ? selectedPreset.text : ''),
is_internal: isInternal,
prepaid_card_id: prepaidCardId
prepaid_card_id: prepaidCardId,
rate_multiplier: selectedPreset ? Number(selectedPreset.multiplier) : 1.0
})
});

View File

@ -116,6 +116,7 @@ class OrderService:
WHERE t.customer_id = %s
AND t.status = 'approved'
AND t.billable = true
AND COALESCE(t.billing_method, 'invoice') NOT IN ('prepaid', 'prepaid_card')
ORDER BY COALESCE(c.id, s.id), t.worked_date
"""
approved_times = execute_query(query, (customer_id,))

View File

@ -55,6 +55,8 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/timetracking")
LOCKED_PREPAID_STATUSES = {"closed", "cancelled", "depleted", "expired"}
def _resolve_current_user_id(current_user: Optional[dict]) -> Optional[int]:
if not current_user:
@ -140,6 +142,26 @@ def _legacy_status_from_entry_status(entry_status: str) -> str:
return "pending"
def _assert_prepaid_entry_editable(entry: Dict[str, Any]) -> None:
prepaid_card_id = entry.get("prepaid_card_id")
if not prepaid_card_id:
return
card = execute_query_single(
"SELECT status FROM tticket_prepaid_cards WHERE id = %s",
(prepaid_card_id,)
)
if not card:
raise HTTPException(status_code=409, detail="Linked prepaid card not found")
card_status = str(card.get("status") or "").lower()
if card_status in LOCKED_PREPAID_STATUSES:
raise HTTPException(
status_code=409,
detail=f"Time entry is locked because prepaid card is {card_status}"
)
def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> Optional[int]:
"""Resolve tmodule customer_id for a case (tmodule_times FK target)."""
try:
@ -2754,12 +2776,14 @@ async def patch_time_entry_v1(
time_id: int,
payload: Dict[str, Any] = Body(...)
):
"""Patch udvalgte felter på tidsentry. Faktisk tid ændres kun via start/slut."""
"""Patch udvalgte felter på tidsentry. Faktisk tid ændres kun via eksplicit brugerinput, aldrig automatisk."""
try:
existing = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
if not existing:
raise HTTPException(status_code=404, detail="Time entry not found")
_assert_prepaid_entry_editable(existing)
updates: Dict[str, Any] = {}
allowed_direct = [
"description", "entry_type", "kilde", "entry_status", "billable", "worked_date",
@ -2769,6 +2793,15 @@ async def patch_time_entry_v1(
if key in payload:
updates[key] = payload.get(key)
if "rate_multiplier" in payload:
try:
rate_multiplier = float(payload.get("rate_multiplier") or 0)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="rate_multiplier must be a number")
if rate_multiplier <= 0:
raise HTTPException(status_code=400, detail="rate_multiplier must be > 0")
updates["rate_multiplier"] = rate_multiplier
start_tid = _parse_iso_datetime(payload.get("start_tid")) if "start_tid" in payload else existing.get("start_tid")
slut_tid = _parse_iso_datetime(payload.get("slut_tid")) if "slut_tid" in payload else existing.get("slut_tid")
if "start_tid" in payload:
@ -2776,13 +2809,16 @@ async def patch_time_entry_v1(
if "slut_tid" in payload:
updates["slut_tid"] = slut_tid
recalculated_minutes = _minutes_between(start_tid, slut_tid)
if recalculated_minutes is not None:
updates["faktisk_tid_min"] = recalculated_minutes
updates["original_hours"] = max(recalculated_minutes / 60.0, 0.01)
# Never auto-change actual time from start/end edits.
if "faktisk_tid_min" in payload:
explicit_minutes = int(payload.get("faktisk_tid_min") or 0)
if explicit_minutes <= 0:
raise HTTPException(status_code=400, detail="faktisk_tid_min must be > 0")
updates["faktisk_tid_min"] = explicit_minutes
updates["original_hours"] = max(explicit_minutes / 60.0, 0.01)
if "fakturerbar_tid_min" not in updates:
block = int(updates.get("round_block_min") or existing.get("round_block_min") or 30)
updates["fakturerbar_tid_min"] = _round_up_minutes(recalculated_minutes, block)
updates["fakturerbar_tid_min"] = _round_up_minutes(explicit_minutes, block)
if "entry_status" in updates:
updates["status"] = _legacy_status_from_entry_status(updates["entry_status"])
@ -2826,6 +2862,8 @@ async def approve_time_entry_v1(
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
_assert_prepaid_entry_editable(entry)
entry_type = payload.get("entry_type") or entry.get("entry_type") or "ukendt"
is_admin_approver = bool((current_user or {}).get("is_superadmin") or (current_user or {}).get("is_shadow_admin"))
if entry_type == "ukendt":
@ -2982,6 +3020,8 @@ async def create_internal_time_entry(
card = execute_query_single("SELECT * FROM tticket_prepaid_cards WHERE id = %s", (prepaid_card_id,))
if not card:
raise HTTPException(status_code=404, detail="Prepaid card not found")
if str(card.get("status") or "").lower() != "active":
raise HTTPException(status_code=409, detail="Prepaid card is locked and cannot receive new time entries")
rounding_minutes = int(card.get('rounding_minutes') or 0)
rounded_hours = hours_decimal

View File

@ -0,0 +1,113 @@
-- Migration 194: Time entry billing extensions
-- Adds per-entry controls for extra billed time and extended support pricing.
ALTER TABLE tticket_worklog
ADD COLUMN IF NOT EXISTS extra_billed_hours DECIMAL(6,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS extended_support_flag BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS rate_multiplier DECIMAL(6,3) DEFAULT 1.000,
ADD COLUMN IF NOT EXISTS manual_hourly_rate DECIMAL(10,2),
ADD COLUMN IF NOT EXISTS manual_rounded_hours DECIMAL(6,2),
ADD COLUMN IF NOT EXISTS rounding_override_reason TEXT;
ALTER TABLE tmodule_times
ADD COLUMN IF NOT EXISTS extra_billed_hours DECIMAL(6,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS extended_support_flag BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS rate_multiplier DECIMAL(6,3) DEFAULT 1.000,
ADD COLUMN IF NOT EXISTS manual_hourly_rate DECIMAL(10,2),
ADD COLUMN IF NOT EXISTS manual_rounded_hours DECIMAL(6,2),
ADD COLUMN IF NOT EXISTS rounding_override_reason TEXT;
UPDATE tticket_worklog
SET
extra_billed_hours = COALESCE(extra_billed_hours, 0),
extended_support_flag = COALESCE(extended_support_flag, FALSE),
rate_multiplier = COALESCE(rate_multiplier, 1.000);
UPDATE tmodule_times
SET
extra_billed_hours = COALESCE(extra_billed_hours, 0),
extended_support_flag = COALESCE(extended_support_flag, FALSE),
rate_multiplier = COALESCE(rate_multiplier, 1.000);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tticket_worklog_extra_billed_hours_nonnegative'
AND conrelid = 'tticket_worklog'::regclass
) THEN
ALTER TABLE tticket_worklog
ADD CONSTRAINT tticket_worklog_extra_billed_hours_nonnegative
CHECK (extra_billed_hours IS NULL OR extra_billed_hours >= 0);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tticket_worklog_rate_multiplier_positive'
AND conrelid = 'tticket_worklog'::regclass
) THEN
ALTER TABLE tticket_worklog
ADD CONSTRAINT tticket_worklog_rate_multiplier_positive
CHECK (rate_multiplier IS NULL OR rate_multiplier > 0);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tticket_worklog_manual_hourly_rate_positive'
AND conrelid = 'tticket_worklog'::regclass
) THEN
ALTER TABLE tticket_worklog
ADD CONSTRAINT tticket_worklog_manual_hourly_rate_positive
CHECK (manual_hourly_rate IS NULL OR manual_hourly_rate > 0);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tticket_worklog_manual_rounded_hours_positive'
AND conrelid = 'tticket_worklog'::regclass
) THEN
ALTER TABLE tticket_worklog
ADD CONSTRAINT tticket_worklog_manual_rounded_hours_positive
CHECK (manual_rounded_hours IS NULL OR manual_rounded_hours > 0);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tmodule_times_extra_billed_hours_nonnegative'
AND conrelid = 'tmodule_times'::regclass
) THEN
ALTER TABLE tmodule_times
ADD CONSTRAINT tmodule_times_extra_billed_hours_nonnegative
CHECK (extra_billed_hours IS NULL OR extra_billed_hours >= 0);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tmodule_times_rate_multiplier_positive'
AND conrelid = 'tmodule_times'::regclass
) THEN
ALTER TABLE tmodule_times
ADD CONSTRAINT tmodule_times_rate_multiplier_positive
CHECK (rate_multiplier IS NULL OR rate_multiplier > 0);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tmodule_times_manual_hourly_rate_positive'
AND conrelid = 'tmodule_times'::regclass
) THEN
ALTER TABLE tmodule_times
ADD CONSTRAINT tmodule_times_manual_hourly_rate_positive
CHECK (manual_hourly_rate IS NULL OR manual_hourly_rate > 0);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tmodule_times_manual_rounded_hours_positive'
AND conrelid = 'tmodule_times'::regclass
) THEN
ALTER TABLE tmodule_times
ADD CONSTRAINT tmodule_times_manual_rounded_hours_positive
CHECK (manual_rounded_hours IS NULL OR manual_rounded_hours > 0);
END IF;
END $$;