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 logging
import hmac
import hashlib
from datetime import datetime, timedelta 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 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 from app.core.database import execute_query
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() 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: def _parse_iso_datetime(value: str, fallback: datetime) -> datetime:
if not value: if not value:
return fallback return fallback
@ -302,40 +386,53 @@ async def get_calendar_ical(
types=types, types=types,
) )
lines = [ return _build_ical_response(events, now)
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//BMC Hub//Calendar//DA",
"CALSCALE:GREGORIAN",
"X-WR-CALNAME:BMC Hub Kalender",
]
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([ @router.get("/calendar/ical/subscribe")
"BEGIN:VEVENT", async def get_calendar_ical_subscribe_link(
f"UID:{uid}", request: Request,
f"DTSTAMP:{_format_ical_dt(now)}", current_user: dict = Depends(get_current_user),
f"DTSTART:{_format_ical_dt(start_value)}", ):
f"SUMMARY:{summary}", """Return a tokenized iCal subscription URL suitable for Outlook/Internet Calendar."""
f"DESCRIPTION:{description}", expires_at = datetime.utcnow() + timedelta(days=3650)
"END:VEVENT", 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), @router.get("/calendar/ical/feed")
media_type="text/calendar; charset=utf-8", 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>Status: <span id="calendarStatus">Klar</span></div>
</div> </div>
<div class="hero-ical"> <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> </div>
<div class="calendar-filter-card"> <div class="calendar-filter-card">
@ -574,6 +582,9 @@
const rangeLabelEl = document.getElementById('rangeLabel'); const rangeLabelEl = document.getElementById('rangeLabel');
const customerSelect = document.getElementById('customerSelect'); const customerSelect = document.getElementById('customerSelect');
const customerSearch = document.getElementById('customerSearch'); 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 mineToggle = document.getElementById('mineToggle');
const allToggle = document.getElementById('allToggle'); const allToggle = document.getElementById('allToggle');
const viewButtons = document.getElementById('viewButtons'); const viewButtons = document.getElementById('viewButtons');
@ -589,6 +600,38 @@
let onlyMine = true; 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) { function setToggle(activeMine) {
onlyMine = activeMine; onlyMine = activeMine;
mineToggle.classList.toggle('active', activeMine); mineToggle.classList.toggle('active', activeMine);
@ -717,6 +760,7 @@
calendar.render(); calendar.render();
loadCustomers(); loadCustomers();
loadIcalSubscriptionLink();
mineToggle.addEventListener('click', () => setToggle(true)); mineToggle.addEventListener('click', () => setToggle(true));
allToggle.addEventListener('click', () => setToggle(false)); allToggle.addEventListener('click', () => setToggle(false));

View File

@ -6248,7 +6248,7 @@
<input type="number" class="form-control form-control-sm" id="quickTimeMinutes" name="minutes" <input type="number" class="form-control form-control-sm" id="quickTimeMinutes" name="minutes"
min="0" max="59" step="15" value="0" required> min="0" max="59" step="15" value="0" required>
</div> </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> <label for="quickTimeBillingMethod" class="form-label small mb-1">Afregning</label>
<select class="form-select form-select-sm" id="quickTimeBillingMethod" name="billing_method"> <select class="form-select form-select-sm" id="quickTimeBillingMethod" name="billing_method">
<option value="invoice" selected>Faktura</option> <option value="invoice" selected>Faktura</option>
@ -6270,7 +6270,13 @@
<option value="warranty">Garanti</option> <option value="warranty">Garanti</option>
</select> </select>
</div> </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> <label for="quickTimeDescription" class="form-label small mb-1">Beskrivelse</label>
<input type="text" class="form-control form-control-sm" id="quickTimeDescription" name="description" <input type="text" class="form-control form-control-sm" id="quickTimeDescription" name="description"
placeholder="Hvad har du lavet?" required> placeholder="Hvad har du lavet?" required>
@ -10220,6 +10226,12 @@
<label class="form-label">Beskrivelse</label> <label class="form-label">Beskrivelse</label>
<input type="text" class="form-control" id="sol_time_desc" placeholder="F.eks. afsluttede løsning"> <input type="text" class="form-control" id="sol_time_desc" placeholder="F.eks. afsluttede løsning">
</div> </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="col-12">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="sol_time_internal"> <input class="form-check-input" type="checkbox" id="sol_time_internal">
@ -10403,6 +10415,13 @@
<label class="form-label">Beskrivelse</label> <label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="time_desc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea> <textarea class="form-control" id="time_desc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
</div> </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> </div>
</form> </form>
</div> </div>
@ -10418,6 +10437,90 @@
<!-- Script for Solution/Time --> <!-- Script for Solution/Time -->
<script> <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() { function showCreateSolutionModal() {
const addTimeCheckbox = document.getElementById('sol_add_time'); const addTimeCheckbox = document.getElementById('sol_add_time');
const timeFields = document.getElementById('sol_time_fields'); const timeFields = document.getElementById('sol_time_fields');
@ -10435,6 +10538,11 @@
if (timeTotal) timeTotal.textContent = 'Total: 0.00 timer'; if (timeTotal) timeTotal.textContent = 'Total: 0.00 timer';
const timeDesc = document.getElementById('sol_time_desc'); const timeDesc = document.getElementById('sol_time_desc');
if (timeDesc) timeDesc.value = ''; if (timeDesc) timeDesc.value = '';
const timeMultiplier = document.getElementById('sol_time_multiplier_preset');
if (timeMultiplier) {
renderSolutionTimeMultiplierPresetOptions();
timeMultiplier.value = '';
}
const timeInternal = document.getElementById('sol_time_internal'); const timeInternal = document.getElementById('sol_time_internal');
if (timeInternal) timeInternal.checked = false; if (timeInternal) timeInternal.checked = false;
new bootstrap.Modal(document.getElementById('createSolutionModal')).show(); new bootstrap.Modal(document.getElementById('createSolutionModal')).show();
@ -10471,6 +10579,10 @@
if (res.ok) { if (res.ok) {
if (addTime && timeTotal > 0) { if (addTime && timeTotal > 0) {
const solution = await res.json(); 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 = { const timePayload = {
sag_id: data.sag_id, sag_id: data.sag_id,
solution_id: solution.id, solution_id: solution.id,
@ -10480,6 +10592,10 @@
is_internal: document.getElementById('sol_time_internal').checked, is_internal: document.getElementById('sol_time_internal').checked,
work_type: 'support' 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', { const timeRes = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
@ -10499,6 +10615,7 @@
function showAddTimeModal() { function showAddTimeModal() {
// Set date to today // Set date to today
document.getElementById('time_date').valueAsDate = new Date(); document.getElementById('time_date').valueAsDate = new Date();
renderSagTimeMultiplierPresetOptions();
// Reset fields // Reset fields
if(document.getElementById('time_total_minutes')) { if(document.getElementById('time_total_minutes')) {
@ -10507,6 +10624,7 @@
document.getElementById('time_end_input').value = ''; document.getElementById('time_end_input').value = '';
} }
document.getElementById('time_desc').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_internal')) document.getElementById('time_internal').checked = false;
if(document.getElementById('time_billing_method')) document.getElementById('time_billing_method').value = 'invoice'; 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'; if(document.getElementById('time_work_type')) document.getElementById('time_work_type').value = 'support';
@ -10533,6 +10651,12 @@
const solMinutes = document.getElementById('sol_time_minutes'); const solMinutes = document.getElementById('sol_time_minutes');
if (solHours) solHours.addEventListener('input', updateSolutionTimeTotal); if (solHours) solHours.addEventListener('input', updateSolutionTimeTotal);
if (solMinutes) solMinutes.addEventListener('input', updateSolutionTimeTotal); if (solMinutes) solMinutes.addEventListener('input', updateSolutionTimeTotal);
loadSagTimeMultiplierPresets().then(() => {
renderSagTimeMultiplierPresetOptions();
renderQuickTimeMultiplierPresetOptions();
renderSolutionTimeMultiplierPresetOptions();
});
}); });
function bindTimeModalCalculations() { function bindTimeModalCalculations() {
@ -10634,6 +10758,19 @@
billing_method: document.getElementById('time_billing_method').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 { try {
const res = await fetch('/api/v1/timetracking/time/manual', { const res = await fetch('/api/v1/timetracking/time/manual', {
method: 'POST', method: 'POST',
@ -14591,6 +14728,11 @@
const isInternal = billingMethod === 'internal'; const isInternal = billingMethod === 'internal';
const quickPresetSelect = document.getElementById('quickTimeMultiplierPreset');
const selectedPreset = quickPresetSelect && quickPresetSelect.value !== ''
? sagTimeMultiplierPresets[Number(quickPresetSelect.value)]
: null;
// Build payload // Build payload
const payload = { const payload = {
sag_id: {{ case.id }}, sag_id: {{ case.id }},
@ -14609,6 +14751,11 @@
payload.fixed_price_agreement_id = fixedPriceAgreementId; payload.fixed_price_agreement_id = fixedPriceAgreementId;
} }
if (selectedPreset) {
payload.rate_multiplier = Number(selectedPreset.multiplier);
payload.multiplier_text = selectedPreset.text;
}
try { try {
const response = await fetch('/api/v1/timetracking/entries/internal', { const response = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST', method: 'POST',
@ -15363,6 +15510,11 @@
<option value="internal">Intern</option> <option value="internal">Intern</option>
<option value="prepaid">Forudbetalt</option> <option value="prepaid">Forudbetalt</option>
</select></div> </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> <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>`, <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>` `<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; return;
} }
const billing = document.getElementById('rqt_billing')?.value || 'invoice'; 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 = { const payload = {
sag_id: caseId, sag_id: caseId,
worked_date: document.getElementById('rqt_date').value, worked_date: document.getElementById('rqt_date').value,
original_hours: totalHours, original_hours: totalHours,
description: document.getElementById('rqt_desc').value, description: description,
billing_method: billing, billing_method: billing,
is_internal: billing === 'internal', 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(); const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; } if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; }
try { try {

View File

@ -7045,7 +7045,7 @@
<input type="number" class="form-control form-control-sm" id="quickTimeMinutes" name="minutes" <input type="number" class="form-control form-control-sm" id="quickTimeMinutes" name="minutes"
min="0" max="59" step="15" value="0" required> min="0" max="59" step="15" value="0" required>
</div> </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> <label for="quickTimeBillingMethod" class="form-label small mb-1">Afregning</label>
<select class="form-select form-select-sm" id="quickTimeBillingMethod" name="billing_method"> <select class="form-select form-select-sm" id="quickTimeBillingMethod" name="billing_method">
<option value="invoice" selected>Faktura</option> <option value="invoice" selected>Faktura</option>
@ -7067,7 +7067,13 @@
<option value="warranty">Garanti</option> <option value="warranty">Garanti</option>
</select> </select>
</div> </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> <label for="quickTimeDescription" class="form-label small mb-1">Beskrivelse</label>
<input type="text" class="form-control form-control-sm" id="quickTimeDescription" name="description" <input type="text" class="form-control form-control-sm" id="quickTimeDescription" name="description"
placeholder="Hvad har du lavet?" required> placeholder="Hvad har du lavet?" required>
@ -12326,6 +12332,12 @@
<label class="form-label">Beskrivelse</label> <label class="form-label">Beskrivelse</label>
<input type="text" class="form-control" id="sol_time_desc" placeholder="F.eks. afsluttede løsning"> <input type="text" class="form-control" id="sol_time_desc" placeholder="F.eks. afsluttede løsning">
</div> </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="col-12">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="sol_time_internal"> <input class="form-check-input" type="checkbox" id="sol_time_internal">
@ -12509,6 +12521,13 @@
<label class="form-label">Beskrivelse</label> <label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="time_desc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea> <textarea class="form-control" id="time_desc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
</div> </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> </div>
</form> </form>
</div> </div>
@ -12524,6 +12543,90 @@
<!-- Script for Solution/Time --> <!-- Script for Solution/Time -->
<script> <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() { function showCreateSolutionModal() {
const addTimeCheckbox = document.getElementById('sol_add_time'); const addTimeCheckbox = document.getElementById('sol_add_time');
const timeFields = document.getElementById('sol_time_fields'); const timeFields = document.getElementById('sol_time_fields');
@ -12541,6 +12644,11 @@
if (timeTotal) timeTotal.textContent = 'Total: 0.00 timer'; if (timeTotal) timeTotal.textContent = 'Total: 0.00 timer';
const timeDesc = document.getElementById('sol_time_desc'); const timeDesc = document.getElementById('sol_time_desc');
if (timeDesc) timeDesc.value = ''; if (timeDesc) timeDesc.value = '';
const timeMultiplier = document.getElementById('sol_time_multiplier_preset');
if (timeMultiplier) {
renderSolutionTimeMultiplierPresetOptions();
timeMultiplier.value = '';
}
const timeInternal = document.getElementById('sol_time_internal'); const timeInternal = document.getElementById('sol_time_internal');
if (timeInternal) timeInternal.checked = false; if (timeInternal) timeInternal.checked = false;
new bootstrap.Modal(document.getElementById('createSolutionModal')).show(); new bootstrap.Modal(document.getElementById('createSolutionModal')).show();
@ -12577,6 +12685,10 @@
if (res.ok) { if (res.ok) {
if (addTime && timeTotal > 0) { if (addTime && timeTotal > 0) {
const solution = await res.json(); 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 = { const timePayload = {
sag_id: data.sag_id, sag_id: data.sag_id,
solution_id: solution.id, solution_id: solution.id,
@ -12586,6 +12698,10 @@
is_internal: document.getElementById('sol_time_internal').checked, is_internal: document.getElementById('sol_time_internal').checked,
work_type: 'support' 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', { const timeRes = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
@ -12605,6 +12721,7 @@
function showAddTimeModal() { function showAddTimeModal() {
// Set date to today // Set date to today
document.getElementById('time_date').valueAsDate = new Date(); document.getElementById('time_date').valueAsDate = new Date();
renderSagTimeMultiplierPresetOptions();
// Reset fields // Reset fields
if(document.getElementById('time_total_minutes')) { if(document.getElementById('time_total_minutes')) {
@ -12613,6 +12730,7 @@
document.getElementById('time_end_input').value = ''; document.getElementById('time_end_input').value = '';
} }
document.getElementById('time_desc').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_internal')) document.getElementById('time_internal').checked = false;
if(document.getElementById('time_billing_method')) document.getElementById('time_billing_method').value = 'invoice'; 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'; if(document.getElementById('time_work_type')) document.getElementById('time_work_type').value = 'support';
@ -12639,6 +12757,12 @@
const solMinutes = document.getElementById('sol_time_minutes'); const solMinutes = document.getElementById('sol_time_minutes');
if (solHours) solHours.addEventListener('input', updateSolutionTimeTotal); if (solHours) solHours.addEventListener('input', updateSolutionTimeTotal);
if (solMinutes) solMinutes.addEventListener('input', updateSolutionTimeTotal); if (solMinutes) solMinutes.addEventListener('input', updateSolutionTimeTotal);
loadSagTimeMultiplierPresets().then(() => {
renderSagTimeMultiplierPresetOptions();
renderQuickTimeMultiplierPresetOptions();
renderSolutionTimeMultiplierPresetOptions();
});
}); });
function bindTimeModalCalculations() { function bindTimeModalCalculations() {
@ -12740,6 +12864,19 @@
billing_method: document.getElementById('time_billing_method').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 { try {
const res = await fetch('/api/v1/timetracking/time/manual', { const res = await fetch('/api/v1/timetracking/time/manual', {
method: 'POST', method: 'POST',
@ -16710,6 +16847,11 @@
const isInternal = billingMethod === 'internal'; const isInternal = billingMethod === 'internal';
const quickPresetSelect = document.getElementById('quickTimeMultiplierPreset');
const selectedPreset = quickPresetSelect && quickPresetSelect.value !== ''
? sagTimeMultiplierPresets[Number(quickPresetSelect.value)]
: null;
// Build payload // Build payload
const payload = { const payload = {
sag_id: {{ case.id }}, sag_id: {{ case.id }},
@ -16728,6 +16870,11 @@
payload.fixed_price_agreement_id = fixedPriceAgreementId; payload.fixed_price_agreement_id = fixedPriceAgreementId;
} }
if (selectedPreset) {
payload.rate_multiplier = Number(selectedPreset.multiplier);
payload.multiplier_text = selectedPreset.text;
}
try { try {
const response = await fetch('/api/v1/timetracking/entries/internal', { const response = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST', method: 'POST',
@ -17482,6 +17629,11 @@
<option value="internal">Intern</option> <option value="internal">Intern</option>
<option value="prepaid">Forudbetalt</option> <option value="prepaid">Forudbetalt</option>
</select></div> </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> <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>`, <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>` `<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; return;
} }
const billing = document.getElementById('rqt_billing')?.value || 'invoice'; 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 = { const payload = {
sag_id: caseId, sag_id: caseId,
worked_date: document.getElementById('rqt_date').value, worked_date: document.getElementById('rqt_date').value,
original_hours: totalHours, original_hours: totalHours,
description: document.getElementById('rqt_desc').value, description: description,
billing_method: billing, billing_method: billing,
is_internal: billing === 'internal', 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(); const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; } if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; }
try { try {

View File

@ -43,6 +43,10 @@ class PrepaidCardRoundingUpdate(BaseModel):
rounding_minutes: int rounding_minutes: int
class PrepaidCardStatusUpdate(BaseModel):
status: str
def _normalize_rounding_minutes(value: Optional[int]) -> int: def _normalize_rounding_minutes(value: Optional[int]) -> int:
if value is None: if value is None:
return 0 return 0
@ -58,6 +62,22 @@ def _apply_rounding(hours: float, rounding_minutes: int) -> float:
return float(rounded) 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]]) @router.get("/prepaid-cards", response_model=List[Dict[str, Any]])
async def get_prepaid_cards(status: Optional[str] = None, customer_id: Optional[int] = None): 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 [] card['transactions'] = transactions or []
# Timelogs from Sag + Ticket worklog (all entries tied to this prepaid card) # 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 SELECT
tm.id, tm.id,
tm.sag_id, tm.sag_id,
@ -182,6 +216,9 @@ async def get_prepaid_card(card_id: int):
tm.description, tm.description,
tm.original_hours, tm.original_hours,
tm.approved_hours, tm.approved_hours,
{tm_manual_select},
{tm_extra_select},
{tm_multiplier_select},
tm.created_at, tm.created_at,
tm.user_name, tm.user_name,
s.titel AS sag_title s.titel AS sag_title
@ -190,7 +227,7 @@ async def get_prepaid_card(card_id: int):
WHERE tm.prepaid_card_id = %s WHERE tm.prepaid_card_id = %s
""", (card_id,)) """, (card_id,))
ticket_logs = execute_query(""" ticket_logs = execute_query(f"""
SELECT SELECT
w.id, w.id,
w.ticket_id, w.ticket_id,
@ -198,6 +235,9 @@ async def get_prepaid_card(card_id: int):
w.description, w.description,
w.hours, w.hours,
w.rounded_hours, w.rounded_hours,
{w_manual_select},
{w_extra_select},
{w_multiplier_select},
w.created_at, w.created_at,
t.subject AS ticket_title, t.subject AS ticket_title,
t.ticket_number t.ticket_number
@ -209,10 +249,17 @@ async def get_prepaid_card(card_id: int):
timelogs = [] timelogs = []
for log in sag_logs or []: for log in sag_logs or []:
actual_hours = float(log['original_hours']) actual_hours = float(log['original_hours'])
# Use stored approved_hours if available, else calculate # Priority: manual override -> approved_hours -> auto rounding
rounded_hours = float(log.get('approved_hours') or 0) if log.get('approved_hours') else _apply_rounding(actual_hours, rounding_minutes) 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({ timelogs.append({
"source": "sag", "source": "sag",
"entry_id": log.get("id"),
"id": log.get("id"),
"time_id": log.get("id"),
"source_id": log.get("sag_id"), "source_id": log.get("sag_id"),
"source_title": log.get("sag_title"), "source_title": log.get("sag_title"),
"worked_date": log.get("worked_date"), "worked_date": log.get("worked_date"),
@ -220,16 +267,25 @@ async def get_prepaid_card(card_id: int):
"description": log.get("description"), "description": log.get("description"),
"user_name": log.get("user_name"), "user_name": log.get("user_name"),
"actual_hours": actual_hours, "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 []: for log in ticket_logs or []:
actual_hours = float(log['hours']) actual_hours = float(log['hours'])
# Use stored rounded_hours if available, else use actual # Priority: manual override -> rounded_hours -> actual
rounded_hours = float(log.get('rounded_hours') or 0) if log.get('rounded_hours') else actual_hours 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") ticket_label = log.get("ticket_number") or log.get("ticket_id")
timelogs.append({ timelogs.append({
"source": "ticket", "source": "ticket",
"entry_id": log.get("id"),
"id": log.get("id"),
"worklog_id": log.get("id"),
"source_id": log.get("ticket_id"), "source_id": log.get("ticket_id"),
"source_title": log.get("ticket_title") or "Ticket", "source_title": log.get("ticket_title") or "Ticket",
"ticket_number": ticket_label, "ticket_number": ticket_label,
@ -238,7 +294,9 @@ async def get_prepaid_card(card_id: int):
"description": log.get("description"), "description": log.get("description"),
"user_name": None, "user_name": None,
"actual_hours": actual_hours, "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( timelogs.sort(
@ -322,12 +380,13 @@ async def create_prepaid_card(card: PrepaidCardCreate):
@router.put("/prepaid-cards/{card_id:int}/status") @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: 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") raise HTTPException(status_code=400, detail="Invalid status")
conn = None 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): if rounding_minutes not in (0, 15, 30, 60):
raise HTTPException(status_code=400, detail="Invalid rounding minutes") 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( result = execute_query(
""" """
UPDATE tticket_prepaid_cards 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.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
import logging import logging
@ -22,13 +22,18 @@ async def prepaid_cards_page(request: Request):
@router.get("/prepaid-cards/{card_id}", response_class=HTMLResponse) @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 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", { return templates.TemplateResponse("detail.html", {
"request": request, "request": request,
"page_title": "Card Details", "page_title": "Card Details",
"card_id": card_id "card_id": card_id_int
}) })

View File

@ -105,13 +105,16 @@
<th>Dato</th> <th>Dato</th>
<th>Sag / Ticket</th> <th>Sag / Ticket</th>
<th>Beskrivelse</th> <th>Beskrivelse</th>
<th class="text-end">Multiplier</th>
<th>Tekst</th>
<th class="text-end">Faktisk tid</th> <th class="text-end">Faktisk tid</th>
<th class="text-end">Afrundet</th> <th class="text-end">Afrundet</th>
<th class="text-end">Handling</th>
</tr> </tr>
</thead> </thead>
<tbody id="timelogsBody"> <tbody id="timelogsBody">
<tr> <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"> <div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>
@ -120,9 +123,10 @@
</tbody> </tbody>
<tfoot class="table-light"> <tfoot class="table-light">
<tr> <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="totalActualHours">-</td>
<td class="text-end fw-bold" id="totalRoundedHours">-</td> <td class="text-end fw-bold" id="totalRoundedHours">-</td>
<td></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@ -131,13 +135,183 @@
</div> </div>
</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> <script>
const cardId = {{ card_id }}; 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 // Load card details
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
editTimelogModal = new bootstrap.Modal(document.getElementById('editTimelogModal'));
loadTimeMultiplierPresets().then(() => {
renderEditMultiplierPresetOptions();
loadCardDetails(); 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() { async function loadCardDetails() {
try { try {
@ -148,6 +322,12 @@ async function loadCardDetails() {
} }
const card = await response.json(); 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 // Update header
document.getElementById('cardNumber').textContent = card.card_number; document.getElementById('cardNumber').textContent = card.card_number;
@ -199,7 +379,7 @@ async function loadCardDetails() {
document.getElementById('cardInfo').innerHTML = ` document.getElementById('cardInfo').innerHTML = `
<div class="col-md-6"> <div class="col-md-6">
<label class="small text-muted">Kortnummer</label> <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>
<div class="col-md-6"> <div class="col-md-6">
<label class="small text-muted">Status</label> <label class="small text-muted">Status</label>
@ -209,10 +389,10 @@ async function loadCardDetails() {
<label class="small text-muted">Kunde</label> <label class="small text-muted">Kunde</label>
<p class="mb-0"> <p class="mb-0">
<a href="/customers/${card.customer_id}" class="text-decoration-none"> <a href="/customers/${card.customer_id}" class="text-decoration-none">
${card.customer_name || '-'} ${customerName}
</a> </a>
</p> </p>
<small class="text-muted">${card.customer_email || ''}</small> <small class="text-muted">${customerEmail}</small>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="small text-muted">Pris pr. Time</label> <label class="small text-muted">Pris pr. Time</label>
@ -241,13 +421,13 @@ async function loadCardDetails() {
${card.economic_invoice_number ? ` ${card.economic_invoice_number ? `
<div class="col-md-6"> <div class="col-md-6">
<label class="small text-muted">e-conomic Fakturanr.</label> <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> </div>
` : ''} ` : ''}
${card.notes ? ` ${card.notes ? `
<div class="col-12"> <div class="col-12">
<label class="small text-muted">Bemærkninger</label> <label class="small text-muted">Bemærkninger</label>
<p class="mb-0">${card.notes}</p> <p class="mb-0">${notesHtml}</p>
</div> </div>
` : ''} ` : ''}
`; `;
@ -255,11 +435,27 @@ async function loadCardDetails() {
// Update action buttons // Update action buttons
const actions = []; const actions = [];
if (card.status === 'active') { 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(` actions.push(`
<button class="btn btn-warning w-100 mb-2" onclick="cancelCard()"> <button class="btn btn-warning w-100 mb-2" onclick="cancelCard()">
<i class="bi bi-x-circle"></i> Annuller Kort <i class="bi bi-x-circle"></i> Annuller Kort
</button> </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('') || document.getElementById('actionButtons').innerHTML = actions.join('') ||
'<p class="text-muted text-center mb-0">Ingen handlinger tilgængelige</p>'; '<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'); const roundingSelect = document.getElementById('roundingMinutes');
if (roundingSelect) { if (roundingSelect) {
roundingSelect.value = String(card.rounding_minutes || 0); 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 // Render timelogs
renderTimelogs(card.timelogs || []); renderTimelogs(card.timelogs || []);
@ -277,7 +478,7 @@ async function loadCardDetails() {
document.getElementById('cardInfo').innerHTML = ` document.getElementById('cardInfo').innerHTML = `
<div class="col-12 text-center text-danger py-5"> <div class="col-12 text-center text-danger py-5">
<i class="bi bi-exclamation-circle fs-1 mb-3"></i> <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'"> <button class="btn btn-primary" onclick="window.location.href='/prepaid-cards'">
Tilbage til oversigt Tilbage til oversigt
</button> </button>
@ -288,10 +489,11 @@ async function loadCardDetails() {
function renderTimelogs(timelogs) { function renderTimelogs(timelogs) {
const tbody = document.getElementById('timelogsBody'); const tbody = document.getElementById('timelogsBody');
currentTimelogs = timelogs || [];
if (!timelogs || timelogs.length === 0) { if (!timelogs || timelogs.length === 0) {
tbody.innerHTML = ` 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 Ingen timelogs endnu
</td></tr> </td></tr>
`; `;
@ -303,29 +505,41 @@ function renderTimelogs(timelogs) {
let totalActual = 0; let totalActual = 0;
let totalRounded = 0; let totalRounded = 0;
tbody.innerHTML = timelogs.map(t => { tbody.innerHTML = timelogs.map((t, index) => {
const dateValue = t.worked_date || t.created_at; const dateValue = t.worked_date || t.created_at;
const dateText = dateValue ? new Date(dateValue).toLocaleDateString('da-DK') : '-'; const dateText = dateValue ? new Date(dateValue).toLocaleDateString('da-DK') : '-';
const sourceTitle = escapeHtml(t.source_title || '');
const description = escapeHtml(t.description || '-');
let sourceHtml = '-'; let sourceHtml = '-';
if (t.source === 'sag' && t.source_id) { 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) { } else if (t.source === 'ticket' && t.source_id) {
const ticketLabel = t.ticket_number ? `#${t.ticket_number}` : `#${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 actual = parseFloat(t.actual_hours) || 0;
const rounded = parseFloat(t.rounded_hours) || 0; const rounded = parseFloat(t.rounded_hours) || 0;
const multiplier = parseFloat(t.rate_multiplier) || 1;
const multiplierText = escapeHtml(getMultiplierText(multiplier) || '-');
totalActual += actual; totalActual += actual;
totalRounded += rounded; 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 ` return `
<tr> <tr>
<td>${dateText}</td> <td>${dateText}</td>
<td>${sourceHtml}</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">${actual.toFixed(2)} t</td>
<td class="text-end">${rounded.toFixed(2)} t</td> <td class="text-end">${rounded.toFixed(2)} t</td>
<td class="text-end">${actionHtml}</td>
</tr> </tr>
`; `;
}).join(''); }).join('');
@ -335,9 +549,111 @@ function renderTimelogs(timelogs) {
document.getElementById('totalRoundedHours').textContent = totalRounded.toFixed(2) + ' t'; 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) { function getStatusBadge(status) {
const badges = { const badges = {
'active': '<span class="badge bg-success">Aktiv</span>', 'active': '<span class="badge bg-success">Aktiv</span>',
'closed': '<span class="badge bg-dark">Lukket</span>',
'depleted': '<span class="badge bg-secondary">Opbrugt</span>', 'depleted': '<span class="badge bg-secondary">Opbrugt</span>',
'expired': '<span class="badge bg-danger">Udløbet</span>', 'expired': '<span class="badge bg-danger">Udløbet</span>',
'cancelled': '<span class="badge bg-warning">Annulleret</span>' 'cancelled': '<span class="badge bg-warning">Annulleret</span>'
@ -345,30 +661,50 @@ function getStatusBadge(status) {
return badges[status] || status; return badges[status] || status;
} }
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: nextStatus })
});
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 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() { async function cancelCard() {
if (!confirm('Er du sikker på at du vil annullere dette kort?')) { if (!confirm('Er du sikker på at du vil annullere dette kort?')) {
return; return;
} }
try { await updateCardStatus('cancelled', '✅ Kort annulleret');
const response = await fetch(`/api/v1/prepaid-cards/${cardId}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'cancelled' })
});
if (!response.ok) throw new Error('Fejl ved annullering');
alert('✅ Kort annulleret');
loadCardDetails(); // Reload
} catch (error) {
console.error('Error cancelling card:', error);
alert('❌ Fejl: ' + error.message);
}
} }
async function saveRounding() { async function saveRounding() {
if (currentCardStatus === 'closed') {
alert('Kortet er lukket og afrunding kan ikke ændres');
return;
}
const select = document.getElementById('roundingMinutes'); const select = document.getElementById('roundingMinutes');
if (!select) return; if (!select) return;
const roundingMinutes = parseInt(select.value, 10) || 0; const roundingMinutes = parseInt(select.value, 10) || 0;
@ -382,6 +718,7 @@ async function saveRounding() {
if (!response.ok) throw new Error('Fejl ved opdatering'); if (!response.ok) throw new Error('Fejl ved opdatering');
currentCardRoundingMinutes = roundingMinutes;
alert('✅ Afrunding opdateret'); alert('✅ Afrunding opdateret');
loadCardDetails(); loadCardDetails();
} catch (error) { } catch (error) {

View File

@ -98,6 +98,7 @@
<select class="form-select" id="statusFilter" onchange="loadCards()"> <select class="form-select" id="statusFilter" onchange="loadCards()">
<option value="">Alle</option> <option value="">Alle</option>
<option value="active">Aktive</option> <option value="active">Aktive</option>
<option value="closed">Lukket</option>
<option value="depleted">Opbrugt</option> <option value="depleted">Opbrugt</option>
<option value="expired">Udløbet</option> <option value="expired">Udløbet</option>
<option value="cancelled">Annulleret</option> <option value="cancelled">Annulleret</option>
@ -254,6 +255,15 @@
<script> <script>
let createCardModal; let createCardModal;
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
createCardModal = new bootstrap.Modal(document.getElementById('createCardModal')); createCardModal = new bootstrap.Modal(document.getElementById('createCardModal'));
@ -307,8 +317,8 @@ async function loadCards() {
} catch (error) { } catch (error) {
console.error('Error loading cards:', error); console.error('Error loading cards:', error);
document.getElementById('cardsTableBody').innerHTML = ` document.getElementById('cardsTableBody').innerHTML = `
<tr><td colspan="10" class="text-center text-danger"> <tr><td colspan="11" class="text-center text-danger">
❌ Fejl ved indlæsning: ${error.message} ❌ Fejl ved indlæsning: ${escapeHtml(error.message)}
</td></tr> </td></tr>
`; `;
} }
@ -328,6 +338,9 @@ function renderCards(cards) {
} }
tbody.innerHTML = cards.map(card => { 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 statusBadge = getStatusBadge(card.status);
const expiresAt = card.expires_at ? const expiresAt = card.expires_at ?
new Date(card.expires_at).toLocaleDateString('da-DK') : '-'; new Date(card.expires_at).toLocaleDateString('da-DK') : '-';
@ -351,12 +364,12 @@ function renderCards(cards) {
<tr> <tr>
<td> <td>
<a href="/prepaid-cards/${card.id}" class="text-decoration-none"> <a href="/prepaid-cards/${card.id}" class="text-decoration-none">
<strong>${card.card_number}</strong> <strong>${cardNumber}</strong>
</a> </a>
</td> </td>
<td> <td>
<div>${card.customer_name || '-'}</div> <div>${customerName}</div>
<small class="text-muted">${card.customer_email || ''}</small> <small class="text-muted">${customerEmail}</small>
</td> </td>
<td class="text-end">${purchasedHours.toFixed(1)} t</td> <td class="text-end">${purchasedHours.toFixed(1)} t</td>
<td class="text-end">${usedHours.toFixed(1)} t</td> <td class="text-end">${usedHours.toFixed(1)} t</td>
@ -405,6 +418,7 @@ function renderCards(cards) {
function getStatusBadge(status) { function getStatusBadge(status) {
const badges = { const badges = {
'active': '<span class="badge bg-success">Aktiv</span>', 'active': '<span class="badge bg-success">Aktiv</span>',
'closed': '<span class="badge bg-dark">Lukket</span>',
'depleted': '<span class="badge bg-secondary">Opbrugt</span>', 'depleted': '<span class="badge bg-secondary">Opbrugt</span>',
'expired': '<span class="badge bg-danger">Udløbet</span>', 'expired': '<span class="badge bg-danger">Udløbet</span>',
'cancelled': '<span class="badge bg-warning">Annulleret</span>' 'cancelled': '<span class="badge bg-warning">Annulleret</span>'
@ -466,9 +480,9 @@ function renderCustomerDropdown(customers) {
} }
list.innerHTML = customers.map(c => ` list.innerHTML = customers.map(c => `
<a href="#" class="dropdown-item py-2 border-bottom" onclick="selectCustomer(${c.id}, '${c.name.replace(/'/g, "\\'")}')"> <a href="#" class="dropdown-item py-2 border-bottom" onclick='selectCustomer(${Number(c.id)}, ${JSON.stringify(c.name || '')})'>
<div class="fw-bold">${c.name}</div> <div class="fw-bold">${escapeHtml(c.name || '')}</div>
${c.email ? `<small class="text-muted">${c.email}</small>` : ''} ${c.email ? `<small class="text-muted">${escapeHtml(c.email)}</small>` : ''}
</a> </a>
`).join(''); `).join('');
} }
@ -477,7 +491,7 @@ function selectCustomer(id, name) {
document.getElementById('customerId').value = id; document.getElementById('customerId').value = id;
const btn = document.getElementById('customerDropdownBtn'); const btn = document.getElementById('customerDropdownBtn');
btn.innerHTML = ` 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> <i class="bi bi-chevron-down small"></i>
`; `;
btn.classList.add('border-primary'); // Highlight selection 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), (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: if not result:
raise HTTPException(status_code=404, detail="Setting not found") 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>
</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="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
@ -1898,6 +1946,13 @@ let allSettings = [];
let pipelineStagesCache = []; let pipelineStagesCache = [];
let nextcloudInstancesCache = []; let nextcloudInstancesCache = [];
let customersCache = []; 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 = '') { function getSettingValue(key, fallback = '') {
const found = allSettings.find(s => s.key === key); const found = allSettings.find(s => s.key === key);
@ -2305,6 +2360,7 @@ async function loadSettings() {
const response = await fetch('/api/v1/settings'); const response = await fetch('/api/v1/settings');
allSettings = await response.json(); allSettings = await response.json();
displaySettingsByCategory(); displaySettingsByCategory();
loadTimeMultiplierPresets();
renderTelefoniSettings(); renderTelefoniSettings();
renderMissionSettings(); renderMissionSettings();
await loadCaseTypesSetting(); 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() { function displaySettingsByCategory() {
const categories = { const categories = {
company: ['company_name', 'company_cvr', 'company_email', 'company_phone', 'company_website', 'company_address'], 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 description: Optional[str] = None
billing_method: BillingMethod = Field(default=BillingMethod.INVOICE) billing_method: BillingMethod = Field(default=BillingMethod.INVOICE)
is_internal: bool = Field(default=False, description="Skjul for kunde (vises ikke på faktura/portal)") 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') @field_validator('hours')
@classmethod @classmethod
@ -265,6 +271,12 @@ class TTicketWorklogUpdate(BaseModel):
status: Optional[WorklogStatus] = None status: Optional[WorklogStatus] = None
prepaid_card_id: Optional[int] = None prepaid_card_id: Optional[int] = None
is_internal: Optional[bool] = 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): class TTicketWorklog(TTicketWorklogBase):

View File

@ -63,6 +63,8 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
sync_admin_access = require_any_permission("users.manage", "system.admin") 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]: def _get_first_value(data: dict, keys: List[str]) -> Optional[str]:
for key in keys: 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))) 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: def _calculate_hash(data: dict) -> str:
payload = json.dumps(data, sort_keys=True, default=str).encode("utf-8") payload = json.dumps(data, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(payload).hexdigest() return hashlib.sha256(payload).hexdigest()
@ -698,11 +734,15 @@ async def create_worklog(
else: else:
rounded_hours = float(worklog_data.hours) 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( worklog_id = execute_insert(
""" """
INSERT INTO tticket_worklog INSERT INTO tticket_worklog
(ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id, is_internal, rounded_hours) (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) 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 RETURNING id
""", """,
( (
@ -716,7 +756,13 @@ async def create_worklog(
user_id or worklog_data.user_id, user_id or worklog_data.user_id,
prepaid_card_id, prepaid_card_id,
worklog_data.is_internal, 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
) )
) )
@ -767,12 +813,17 @@ async def update_worklog(
if not current: if not current:
raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found") raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found")
_assert_prepaid_card_editable(current.get("prepaid_card_id"))
# Build update query # Build update query
updates = [] updates = []
params = [] params = []
update_dict = update_data.model_dump(exclude_unset=True) 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 # 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': if 'billing_method' in update_dict and update_dict['billing_method'] == 'prepaid_card':
customer_id = current['customer_id'] customer_id = current['customer_id']
@ -810,6 +861,26 @@ async def update_worklog(
status_code=400, status_code=400,
detail="Valgt klippekort er ikke aktivt eller tilhører ikke kunden") 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(): for field, value in update_dict.items():
if hasattr(value, 'value'): if hasattr(value, 'value'):
value = value.value value = value.value
@ -873,7 +944,7 @@ async def review_worklog(
query += " ORDER BY w.work_date DESC, t.customer_id" 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 # Calculate totals
total_hours = Decimal('0') total_hours = Decimal('0')
@ -881,8 +952,8 @@ async def review_worklog(
for w in worklogs or []: for w in worklogs or []:
total_hours += Decimal(str(w['hours'])) total_hours += Decimal(str(w['hours']))
if w['status'] in ['draft', 'billable']: if w['status'] in ['draft', 'billable'] and w.get('billing_method') not in ['prepaid_card', 'prepaid']:
total_billable_hours += Decimal(str(w['hours'])) total_billable_hours += Decimal(str(_compute_billable_hours_for_worklog(w)))
return WorklogReviewResponse( return WorklogReviewResponse(
worklogs=worklogs or [], worklogs=worklogs or [],
@ -911,7 +982,7 @@ async def mark_worklog_billable(
for worklog_id in request.worklog_ids: for worklog_id in request.worklog_ids:
# Get worklog # Get worklog
worklog = execute_query( worklog = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s", "SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,)) (worklog_id,))
@ -923,6 +994,10 @@ async def mark_worklog_billable(
logger.warning(f"⚠️ Worklog {worklog_id} not in draft status, skipping") logger.warning(f"⚠️ Worklog {worklog_id} not in draft status, skipping")
continue 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 # Update to billable
execute_update( execute_update(
"UPDATE tticket_worklog SET status = 'billable' WHERE id = %s", "UPDATE tticket_worklog SET status = 'billable' WHERE id = %s",

View File

@ -624,8 +624,51 @@
// WORKLOG MANAGEMENT // 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() { async function showWorklogModal() {
const today = new Date().toISOString().split('T')[0]; 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 // Fetch Prepaid Cards for this customer
let prepaidOptions = ''; let prepaidOptions = '';
@ -651,6 +694,7 @@
// Store for use in submitWorklog // Store for use in submitWorklog
window._activePrepaidCards = activePrepaidCards; window._activePrepaidCards = activePrepaidCards;
window._timeMultiplierPresets = multiplierPresets;
const modalHtml = ` const modalHtml = `
<div class="modal fade" id="worklogModal" tabindex="-1"> <div class="modal fade" id="worklogModal" tabindex="-1">
@ -700,6 +744,14 @@
<label class="form-label">Beskrivelse</label> <label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="worklogDesc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea> <textarea class="form-control" id="worklogDesc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
</div> </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="col-12">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="worklogInternal"> <input class="form-check-input" type="checkbox" id="worklogInternal">
@ -743,6 +795,18 @@
setTimeout(() => document.getElementById('worklogHours').focus(), 500); 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() { async function submitWorklog() {
const date = document.getElementById('worklogDate').value; const date = document.getElementById('worklogDate').value;
// Calculate hours from split fields // Calculate hours from split fields
@ -754,6 +818,10 @@
let billing = document.getElementById('worklogBilling').value; let billing = document.getElementById('worklogBilling').value;
const desc = document.getElementById('worklogDesc').value; const desc = document.getElementById('worklogDesc').value;
const isInternal = document.getElementById('worklogInternal').checked; 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; let prepaidCardId = null;
@ -782,9 +850,10 @@
hours: hours, hours: hours,
work_type: type, work_type: type,
billing_method: billing, billing_method: billing,
description: desc, description: (desc || '').trim() || (selectedPreset ? selectedPreset.text : ''),
is_internal: isInternal, 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 WHERE t.customer_id = %s
AND t.status = 'approved' AND t.status = 'approved'
AND t.billable = true 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 ORDER BY COALESCE(c.id, s.id), t.worked_date
""" """
approved_times = execute_query(query, (customer_id,)) approved_times = execute_query(query, (customer_id,))

View File

@ -55,6 +55,8 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/timetracking") router = APIRouter(prefix="/timetracking")
LOCKED_PREPAID_STATUSES = {"closed", "cancelled", "depleted", "expired"}
def _resolve_current_user_id(current_user: Optional[dict]) -> Optional[int]: def _resolve_current_user_id(current_user: Optional[dict]) -> Optional[int]:
if not current_user: if not current_user:
@ -140,6 +142,26 @@ def _legacy_status_from_entry_status(entry_status: str) -> str:
return "pending" 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]: def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> Optional[int]:
"""Resolve tmodule customer_id for a case (tmodule_times FK target).""" """Resolve tmodule customer_id for a case (tmodule_times FK target)."""
try: try:
@ -2754,12 +2776,14 @@ async def patch_time_entry_v1(
time_id: int, time_id: int,
payload: Dict[str, Any] = Body(...) 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: try:
existing = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,)) existing = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
if not existing: if not existing:
raise HTTPException(status_code=404, detail="Time entry not found") raise HTTPException(status_code=404, detail="Time entry not found")
_assert_prepaid_entry_editable(existing)
updates: Dict[str, Any] = {} updates: Dict[str, Any] = {}
allowed_direct = [ allowed_direct = [
"description", "entry_type", "kilde", "entry_status", "billable", "worked_date", "description", "entry_type", "kilde", "entry_status", "billable", "worked_date",
@ -2769,6 +2793,15 @@ async def patch_time_entry_v1(
if key in payload: if key in payload:
updates[key] = payload.get(key) 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") 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") slut_tid = _parse_iso_datetime(payload.get("slut_tid")) if "slut_tid" in payload else existing.get("slut_tid")
if "start_tid" in payload: if "start_tid" in payload:
@ -2776,13 +2809,16 @@ async def patch_time_entry_v1(
if "slut_tid" in payload: if "slut_tid" in payload:
updates["slut_tid"] = slut_tid updates["slut_tid"] = slut_tid
recalculated_minutes = _minutes_between(start_tid, slut_tid) # Never auto-change actual time from start/end edits.
if recalculated_minutes is not None: if "faktisk_tid_min" in payload:
updates["faktisk_tid_min"] = recalculated_minutes explicit_minutes = int(payload.get("faktisk_tid_min") or 0)
updates["original_hours"] = max(recalculated_minutes / 60.0, 0.01) 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: if "fakturerbar_tid_min" not in updates:
block = int(updates.get("round_block_min") or existing.get("round_block_min") or 30) 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: if "entry_status" in updates:
updates["status"] = _legacy_status_from_entry_status(updates["entry_status"]) updates["status"] = _legacy_status_from_entry_status(updates["entry_status"])
@ -2826,6 +2862,8 @@ async def approve_time_entry_v1(
if not entry: if not entry:
raise HTTPException(status_code=404, detail="Time entry not found") raise HTTPException(status_code=404, detail="Time entry not found")
_assert_prepaid_entry_editable(entry)
entry_type = payload.get("entry_type") or entry.get("entry_type") or "ukendt" 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")) is_admin_approver = bool((current_user or {}).get("is_superadmin") or (current_user or {}).get("is_shadow_admin"))
if entry_type == "ukendt": 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,)) card = execute_query_single("SELECT * FROM tticket_prepaid_cards WHERE id = %s", (prepaid_card_id,))
if not card: if not card:
raise HTTPException(status_code=404, detail="Prepaid card not found") 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) rounding_minutes = int(card.get('rounding_minutes') or 0)
rounded_hours = hours_decimal 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 $$;