feat(timetracking): start sag tidsforbrug v1 backend+ui

This commit is contained in:
Christian 2026-03-25 16:33:49 +01:00
parent 43fd651723
commit 205c0dab07
4 changed files with 930 additions and 3 deletions

View File

@ -2109,6 +2109,11 @@
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg <i class="bi bi-basket3 me-2"></i>Varekøb & Salg
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="timetracking-tab" data-bs-toggle="tab" data-bs-target="#timetracking" type="button" role="tab" data-module-tab="timetracking" onclick="forceCaseTabActivation('timetracking', this)">
<i class="bi bi-clock-history me-2"></i>Tidsforbrug
</button>
</li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="subscription-tab" data-bs-toggle="tab" data-bs-target="#subscription" type="button" role="tab" data-module-tab="subscription" onclick="forceCaseTabActivation('subscription', this)"> <button class="nav-link" id="subscription-tab" data-bs-toggle="tab" data-bs-target="#subscription" type="button" role="tab" data-module-tab="subscription" onclick="forceCaseTabActivation('subscription', this)">
<i class="bi bi-repeat me-2"></i>Abonnement <i class="bi bi-repeat me-2"></i>Abonnement
@ -2799,6 +2804,7 @@
'wiki': 'Wiki', 'wiki': 'Wiki',
'todo-steps': 'Todo-opgaver', 'todo-steps': 'Todo-opgaver',
'time': 'Tid', 'time': 'Tid',
'timetracking': 'Tidsforbrug',
'solution': 'Løsning', 'solution': 'Løsning',
'sales': 'Varekøb & salg', 'sales': 'Varekøb & salg',
'subscription': 'Abonnement', 'subscription': 'Abonnement',
@ -2877,6 +2883,8 @@
try { try {
if (tabId === 'sales' && typeof loadVarekobSalg === 'function') { if (tabId === 'sales' && typeof loadVarekobSalg === 'function') {
await loadVarekobSalg(); await loadVarekobSalg();
} else if (tabId === 'timetracking' && typeof loadTimeTrackingTab === 'function') {
await loadTimeTrackingTab();
} else if (tabId === 'subscription' && typeof loadSubscriptionForCase === 'function') { } else if (tabId === 'subscription' && typeof loadSubscriptionForCase === 'function') {
await loadSubscriptionForCase(); await loadSubscriptionForCase();
} else if (tabId === 'reminders') { } else if (tabId === 'reminders') {
@ -5007,6 +5015,100 @@
</div> </div>
</div> </div>
<!-- Tidsforbrug Tab -->
<div class="tab-pane fade" id="timetracking" role="tabpanel" tabindex="0" data-module="timetracking" data-has-content="unknown" style="display:none;">
<div id="timeActiveBanner" class="alert alert-warning d-none d-flex justify-content-between align-items-center" role="alert">
<div>
<strong>Aktiv timer:</strong>
<span id="timeActiveBannerText">Du har en aktiv timer.</span>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="stopLiveTimerV1({ entry_status: 'kladde' })">Pause</button>
<button class="btn btn-sm btn-primary" onclick="stopLiveTimerV1({ entry_status: 'afventer' })">Stop og registrer</button>
</div>
</div>
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-stopwatch me-2"></i>Live tracking</h6>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" onclick="startLiveTimerV1()">
<i class="bi bi-play-fill me-1"></i>Start timer
</button>
<button class="btn btn-sm btn-outline-danger" onclick="stopLiveTimerV1({ entry_status: 'afventer' })">
<i class="bi bi-stop-fill me-1"></i>Stop timer
</button>
</div>
</div>
<div class="card-body">
<form id="timeManualFormV1" class="row g-2 align-items-end" onsubmit="createManualTimeV1(event); return false;">
<div class="col-md-2 col-6">
<label class="form-label small mb-1">Dato</label>
<input type="date" class="form-control form-control-sm" id="timeV1Date">
</div>
<div class="col-md-2 col-6">
<label class="form-label small mb-1">Minutter</label>
<input type="number" min="1" class="form-control form-control-sm" id="timeV1Minutes" placeholder="fx 45" required>
</div>
<div class="col-md-2 col-6">
<label class="form-label small mb-1">Type</label>
<select class="form-select form-select-sm" id="timeV1Type">
<option value="ukendt">Ukendt</option>
<option value="manuel" selected>Manuel</option>
<option value="opkald">Opkald</option>
<option value="mail">Mail</option>
<option value="indedesk">IndeDesk</option>
</select>
</div>
<div class="col-md-2 col-6">
<label class="form-label small mb-1">Status</label>
<select class="form-select form-select-sm" id="timeV1Status">
<option value="kladde">Kladde</option>
<option value="afventer" selected>Afventer</option>
<option value="godkendt">Godkendt</option>
</select>
</div>
<div class="col-md-3 col-12">
<label class="form-label small mb-1">Beskrivelse</label>
<input type="text" class="form-control form-control-sm" id="timeV1Description" placeholder="Hvad er udført?">
</div>
<div class="col-md-1 col-12 d-grid">
<button class="btn btn-sm btn-primary" type="submit"><i class="bi bi-plus-lg"></i></button>
</div>
</form>
<div class="small text-muted mt-2">
Faktisk tid gemmes internt. Fakturerbar tid afrundes per blok (standard 30 min).
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-9">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-diagram-3 me-2"></i>Timeline per medarbejder</h6>
<button class="btn btn-sm btn-outline-secondary" onclick="loadTimeTrackingTab()">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater
</button>
</div>
<div class="card-body" id="timeTimelineColumns">
<div class="text-muted text-center py-3">Indlæser tidslinje...</div>
</div>
</div>
</div>
<div class="col-lg-3">
<div class="card">
<div class="card-header">
<h6 class="mb-0 text-primary"><i class="bi bi-arrows-move me-2"></i>Ikke placeret</h6>
</div>
<div class="card-body" id="timeUnplacedEntries">
<div class="text-muted small">Ingen entries uden tidspunkter.</div>
</div>
</div>
</div>
</div>
</div>
<!-- Subscription Tab --> <!-- Subscription Tab -->
<div class="tab-pane fade" id="subscription" role="tabpanel" tabindex="0" data-module="subscription" data-has-content="unknown" style="display:none;"> <div class="tab-pane fade" id="subscription" role="tabpanel" tabindex="0" data-module="subscription" data-has-content="unknown" style="display:none;">
<div class="row g-3"> <div class="row g-3">
@ -6186,6 +6288,185 @@
}); });
</script> </script>
<script>
const timeCaseId = {{ case.id }};
function minutesToLabel(minutes) {
const value = Number(minutes || 0);
const h = Math.floor(value / 60);
const m = value % 60;
return `${h}t ${m}m`;
}
function timeStatusBadge(status) {
if (status === 'godkendt') return '<span class="badge bg-success">Godkendt</span>';
if (status === 'kladde') return '<span class="badge bg-secondary">Kladde</span>';
return '<span class="badge bg-warning text-dark">Afventer</span>';
}
function renderTimeV1Timeline(entries) {
const timeline = document.getElementById('timeTimelineColumns');
const unplaced = document.getElementById('timeUnplacedEntries');
const activeBanner = document.getElementById('timeActiveBanner');
const activeBannerText = document.getElementById('timeActiveBannerText');
if (!timeline || !unplaced) return;
const active = (entries || []).find((entry) => entry.aktiv_timer && !entry.slut_tid);
if (active) {
activeBanner.classList.remove('d-none');
activeBannerText.textContent = `Aktiv på ${active.user_name || 'ukendt bruger'}: ${active.description || 'uden beskrivelse'}`;
} else {
activeBanner.classList.add('d-none');
}
const unplacedEntries = (entries || []).filter((entry) => entry.ikke_placeret || (!entry.start_tid && !entry.slut_tid));
if (!unplacedEntries.length) {
unplaced.innerHTML = '<div class="text-muted small">Ingen entries uden tidspunkter.</div>';
} else {
unplaced.innerHTML = unplacedEntries.map((entry) => {
return `
<div class="border rounded p-2 mb-2">
<div class="small fw-semibold">${entry.description || 'Uden beskrivelse'}</div>
<div class="small text-muted">${minutesToLabel(entry.faktisk_tid_min || Math.round((entry.original_hours || 0) * 60))}</div>
<div class="mt-1">${timeStatusBadge(entry.entry_status || 'afventer')}</div>
</div>
`;
}).join('');
}
if (!entries || !entries.length) {
timeline.innerHTML = '<div class="text-muted text-center py-3">Ingen tidsregistreringer endnu.</div>';
return;
}
const grouped = {};
entries.forEach((entry) => {
const key = `${entry.medarbejder_id || 0}:${entry.user_name || 'Ukendt bruger'}`;
if (!grouped[key]) grouped[key] = [];
grouped[key].push(entry);
});
timeline.innerHTML = Object.entries(grouped).map(([key, rows]) => {
const userName = key.split(':')[1] || 'Ukendt bruger';
const cards = rows.map((entry) => {
const faktisk = entry.faktisk_tid_min || Math.round((entry.original_hours || 0) * 60);
const fakturerbar = entry.fakturerbar_tid_min || Math.round((entry.approved_hours || entry.original_hours || 0) * 60);
const isBillable = entry.billable !== false;
const blockClass = isBillable ? 'border-success bg-success-subtle' : 'border-secondary bg-light';
return `
<div class="border ${blockClass} rounded p-2 mb-2">
<div class="d-flex justify-content-between align-items-start">
<div class="small fw-semibold">${entry.description || 'Uden beskrivelse'}</div>
${timeStatusBadge(entry.entry_status || 'afventer')}
</div>
<div class="small text-muted mt-1">Type: ${entry.entry_type || 'ukendt'} · Kilde: ${entry.kilde || 'manuel'}</div>
<div class="small mt-1">Faktisk: ${minutesToLabel(faktisk)} · Fakturerbar: ${minutesToLabel(fakturerbar)}</div>
</div>
`;
}).join('');
return `
<div class="mb-3">
<div class="fw-semibold mb-2">${userName}</div>
${cards}
</div>
`;
}).join('');
}
async function loadTimeTrackingTab() {
try {
const res = await fetch(`/api/v1/timetracking/time?sag_id=${timeCaseId}`);
if (!res.ok) throw new Error('Kunne ikke hente tidsforbrug');
const entries = await res.json();
renderTimeV1Timeline(entries || []);
setModuleContentState('timetracking', (entries || []).length > 0);
} catch (error) {
console.error(error);
const timeline = document.getElementById('timeTimelineColumns');
if (timeline) {
timeline.innerHTML = '<div class="text-danger text-center py-3">Kunne ikke hente tidsforbrug.</div>';
}
setModuleContentState('timetracking', true);
}
}
async function startLiveTimerV1() {
try {
const res = await fetch('/api/v1/timetracking/time/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: timeCaseId,
entry_type: document.getElementById('timeV1Type')?.value || 'manuel',
beskrivelse: document.getElementById('timeV1Description')?.value || null
})
});
if (!res.ok) throw new Error(await res.text());
await loadTimeTrackingTab();
} catch (error) {
alert('Kunne ikke starte timer: ' + (error.message || 'ukendt fejl'));
}
}
async function stopLiveTimerV1(extra = {}) {
try {
const res = await fetch('/api/v1/timetracking/time/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(extra || {})
});
if (!res.ok) throw new Error(await res.text());
await loadTimeTrackingTab();
} catch (error) {
alert('Kunne ikke stoppe timer: ' + (error.message || 'ukendt fejl'));
}
}
async function createManualTimeV1(event) {
event.preventDefault();
const minutes = Number(document.getElementById('timeV1Minutes')?.value || 0);
if (minutes <= 0) {
alert('Indtast minutter over 0');
return;
}
const payload = {
sag_id: timeCaseId,
faktisk_tid_min: minutes,
worked_date: document.getElementById('timeV1Date')?.value || null,
entry_type: document.getElementById('timeV1Type')?.value || 'manuel',
entry_status: document.getElementById('timeV1Status')?.value || 'afventer',
beskrivelse: document.getElementById('timeV1Description')?.value || null,
kilde: 'manuel'
};
try {
const res = await fetch('/api/v1/timetracking/time/manual', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
const minutesInput = document.getElementById('timeV1Minutes');
const descInput = document.getElementById('timeV1Description');
if (minutesInput) minutesInput.value = '';
if (descInput) descInput.value = '';
await loadTimeTrackingTab();
} catch (error) {
alert('Kunne ikke oprette tidsregistrering: ' + (error.message || 'ukendt fejl'));
}
}
document.addEventListener('DOMContentLoaded', () => {
const dateInput = document.getElementById('timeV1Date');
if (dateInput && !dateInput.value) {
dateInput.valueAsDate = new Date();
}
});
</script>
<script> <script>
let reminderUserId = null; let reminderUserId = null;
const remindersCaseId = {{ case.id }}; const remindersCaseId = {{ case.id }};

View File

@ -95,6 +95,17 @@ class TModuleTimeBase(BaseModel):
original_hours: Decimal = Field(..., gt=0, description="Original timer") original_hours: Decimal = Field(..., gt=0, description="Original timer")
worked_date: Optional[date] = None worked_date: Optional[date] = None
user_name: Optional[str] = Field(None, max_length=255, description="Bruger") user_name: Optional[str] = Field(None, max_length=255, description="Bruger")
start_tid: Optional[datetime] = Field(None, description="Starttid for live timer")
slut_tid: Optional[datetime] = Field(None, description="Sluttid for live timer")
faktisk_tid_min: Optional[int] = Field(None, ge=0, description="Reel tid i minutter")
fakturerbar_tid_min: Optional[int] = Field(None, ge=0, description="Fakturerbar tid i minutter")
entry_type: Optional[str] = Field("ukendt", pattern="^(opkald|mail|indedesk|manuel|ukendt)$")
kilde: Optional[str] = Field("manuel", pattern="^(auto|manuel|api)$")
entry_status: Optional[str] = Field("afventer", pattern="^(kladde|afventer|godkendt)$")
medarbejder_id: Optional[int] = Field(None, gt=0)
aktiv_timer: Optional[bool] = False
round_block_min: Optional[int] = Field(30, ge=1, le=240)
ikke_placeret: Optional[bool] = False
class TModuleTimeCreate(TModuleTimeBase): class TModuleTimeCreate(TModuleTimeBase):
@ -110,6 +121,9 @@ class TModuleTimeUpdate(BaseModel):
billable: Optional[bool] = None billable: Optional[bool] = None
is_travel: Optional[bool] = None is_travel: Optional[bool] = None
status: Optional[str] = Field(None, pattern="^(pending|approved|rejected|billed)$") status: Optional[str] = Field(None, pattern="^(pending|approved|rejected|billed)$")
entry_status: Optional[str] = Field(None, pattern="^(kladde|afventer|godkendt)$")
fakturerbar_tid_min: Optional[int] = Field(None, ge=0)
entry_type: Optional[str] = Field(None, pattern="^(opkald|mail|indedesk|manuel|ukendt)$")
class TModuleTimeApproval(BaseModel): class TModuleTimeApproval(BaseModel):

View File

@ -52,6 +52,51 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/timetracking") router = APIRouter(prefix="/timetracking")
def _resolve_current_user_id(current_user: Optional[dict]) -> Optional[int]:
if not current_user:
return None
raw = current_user.get("id") or current_user.get("user_id")
try:
return int(raw) if raw is not None else None
except (TypeError, ValueError):
return None
def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
if isinstance(value, datetime):
return value
normalized = str(value).strip().replace("Z", "+00:00")
try:
return datetime.fromisoformat(normalized)
except ValueError:
return None
def _minutes_between(start: Optional[datetime], end: Optional[datetime]) -> Optional[int]:
if not start or not end:
return None
diff_seconds = int((end - start).total_seconds())
return max(0, diff_seconds // 60)
def _round_up_minutes(minutes: int, block_minutes: int = 30) -> int:
safe_minutes = max(0, int(minutes or 0))
safe_block = max(1, int(block_minutes or 30))
if safe_minutes == 0:
return 0
return ((safe_minutes + safe_block - 1) // safe_block) * safe_block
def _legacy_status_from_entry_status(entry_status: str) -> str:
if entry_status == "godkendt":
return "approved"
if entry_status == "kladde":
return "pending"
return "pending"
# ============================================================================ # ============================================================================
# SYNC ENDPOINTS # SYNC ENDPOINTS
# ============================================================================ # ============================================================================
@ -1758,6 +1803,458 @@ async def uninstall_module(
# INTERNAL / HUB INTEGRATION ENDPOINTS # INTERNAL / HUB INTEGRATION ENDPOINTS
# ============================================================================ # ============================================================================
@router.get("/time", tags=["Internal"])
async def list_time_entries_v1(
sag_id: int = Query(..., gt=0),
day: Optional[date] = Query(None),
medarbejder_id: Optional[int] = Query(None, gt=0),
):
"""List tidsregistreringer for en sag med filtre til timeline UI."""
try:
clauses = ["sag_id = %s"]
params: List[Any] = [sag_id]
if day:
clauses.append("(worked_date = %s OR DATE(start_tid) = %s)")
params.extend([day, day])
if medarbejder_id:
clauses.append("medarbejder_id = %s")
params.append(medarbejder_id)
where_sql = " AND ".join(clauses)
query = f"""
SELECT *
FROM tmodule_times
WHERE {where_sql}
ORDER BY COALESCE(start_tid, worked_date::timestamp, created_at) DESC, id DESC
"""
return execute_query(query, tuple(params))
except Exception as e:
logger.error("❌ Error listing v1 time entries for sag %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to list time entries")
@router.post("/time/start", tags=["Internal"])
async def start_live_timer_v1(
payload: Dict[str, Any] = Body(...),
current_user: Optional[dict] = Depends(get_optional_user)
):
"""Start live timer. Kun én aktiv timer pr. bruger; eksisterende auto-pause'es."""
try:
sag_id = payload.get("sag_id")
if not sag_id:
raise HTTPException(status_code=400, detail="sag_id is required")
bruger_id = _resolve_current_user_id(current_user) or payload.get("medarbejder_id")
if not bruger_id:
raise HTTPException(status_code=400, detail="medarbejder_id could not be resolved")
now = datetime.now()
existing = execute_query_single(
"""
SELECT id, start_tid, round_block_min
FROM tmodule_times
WHERE medarbejder_id = %s AND aktiv_timer = TRUE AND slut_tid IS NULL
ORDER BY start_tid DESC NULLS LAST, id DESC
LIMIT 1
""",
(bruger_id,)
)
paused_entry = None
if existing:
actual_minutes = _minutes_between(existing.get("start_tid"), now) or 0
rounded_minutes = _round_up_minutes(actual_minutes, existing.get("round_block_min") or 30)
execute_update(
"""
UPDATE tmodule_times
SET slut_tid = %s,
aktiv_timer = FALSE,
faktisk_tid_min = %s,
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
original_hours = GREATEST(%s::numeric / 60.0, 0.01),
approved_hours = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE 0 END,
rounded_to = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
entry_status = 'afventer',
status = 'pending'
WHERE id = %s
""",
(now, actual_minutes, rounded_minutes, actual_minutes, rounded_minutes, existing.get("round_block_min") or 30, existing["id"])
)
paused_entry = existing["id"]
default_user_name = (
(current_user or {}).get("username")
or (current_user or {}).get("full_name")
or "Hub User"
)
user_name = payload.get("user_name") or default_user_name
entry_type = payload.get("entry_type") or "manuel"
kilde = payload.get("kilde") or "manuel"
billable = bool(payload.get("fakturerbar", True))
round_block_min = int(payload.get("round_block_min") or 30)
created = execute_query(
"""
INSERT INTO tmodule_times (
sag_id, customer_id, description, original_hours,
worked_date, user_name, status, billable,
start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
entry_type, kilde, entry_status, medarbejder_id,
aktiv_timer, round_block_min, ikke_placeret
) VALUES (
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s
) RETURNING *
""",
(
sag_id,
payload.get("customer_id"),
payload.get("beskrivelse") or payload.get("description"),
0.01,
now.date(),
user_name,
"pending",
billable,
now,
None,
None,
None,
entry_type,
kilde,
"kladde",
bruger_id,
True,
round_block_min,
False,
)
)
return {
"entry": created[0] if created else None,
"paused_entry_id": paused_entry,
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error starting live timer: %s", e)
raise HTTPException(status_code=500, detail="Failed to start timer")
@router.post("/time/stop", tags=["Internal"])
async def stop_live_timer_v1(
payload: Dict[str, Any] = Body(...),
current_user: Optional[dict] = Depends(get_optional_user)
):
"""Stop aktiv timer for bruger eller specifik entry."""
try:
now = datetime.now()
time_id = payload.get("time_id")
bruger_id = _resolve_current_user_id(current_user) or payload.get("medarbejder_id")
if time_id:
entry = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
else:
if not bruger_id:
raise HTTPException(status_code=400, detail="medarbejder_id could not be resolved")
entry = execute_query_single(
"""
SELECT *
FROM tmodule_times
WHERE medarbejder_id = %s AND aktiv_timer = TRUE AND slut_tid IS NULL
ORDER BY start_tid DESC NULLS LAST, id DESC
LIMIT 1
""",
(bruger_id,)
)
if not entry:
raise HTTPException(status_code=404, detail="No active timer found")
start_tid = entry.get("start_tid")
actual_minutes = payload.get("faktisk_tid_min")
if actual_minutes is None:
actual_minutes = _minutes_between(start_tid, now)
actual_minutes = max(0, int(actual_minutes or 0))
block_minutes = int(payload.get("round_block_min") or entry.get("round_block_min") or 30)
manual_billable = payload.get("fakturerbar_tid_min")
billable_minutes = int(manual_billable) if manual_billable is not None else _round_up_minutes(actual_minutes, block_minutes)
entry_status = payload.get("entry_status") or "afventer"
legacy_status = _legacy_status_from_entry_status(entry_status)
result = execute_query(
"""
UPDATE tmodule_times
SET slut_tid = %s,
aktiv_timer = FALSE,
faktisk_tid_min = %s,
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
original_hours = GREATEST(%s::numeric / 60.0, 0.01),
approved_hours = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE 0 END,
rounded_to = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
worked_date = COALESCE(worked_date, %s),
entry_status = %s,
status = %s,
ikke_placeret = FALSE
WHERE id = %s
RETURNING *
""",
(
now,
actual_minutes,
billable_minutes,
actual_minutes,
billable_minutes,
block_minutes,
now.date(),
entry_status,
legacy_status,
entry["id"],
)
)
return result[0] if result else None
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error stopping live timer: %s", e)
raise HTTPException(status_code=500, detail="Failed to stop timer")
@router.post("/time/manual", tags=["Internal"])
async def create_manual_time_v1(
payload: Dict[str, Any] = Body(...),
current_user: Optional[dict] = Depends(get_optional_user)
):
"""Create manuel tidsregistrering med/uden start-slut."""
try:
sag_id = payload.get("sag_id")
if not sag_id:
raise HTTPException(status_code=400, detail="sag_id is required")
bruger_id = _resolve_current_user_id(current_user) or payload.get("medarbejder_id")
default_user_name = (
(current_user or {}).get("username")
or (current_user or {}).get("full_name")
or "Hub User"
)
start_tid = _parse_iso_datetime(payload.get("start_tid"))
slut_tid = _parse_iso_datetime(payload.get("slut_tid"))
actual_minutes = payload.get("faktisk_tid_min")
if actual_minutes is None:
actual_minutes = _minutes_between(start_tid, slut_tid)
if actual_minutes is None:
original_hours = float(payload.get("original_hours") or 0)
actual_minutes = int(round(original_hours * 60))
if actual_minutes <= 0:
raise HTTPException(status_code=400, detail="faktisk_tid_min or original_hours must be > 0")
round_block_min = int(payload.get("round_block_min") or 30)
billable = bool(payload.get("fakturerbar", True))
billable_minutes = payload.get("fakturerbar_tid_min")
if billable_minutes is None:
billable_minutes = _round_up_minutes(actual_minutes, round_block_min)
billable_minutes = int(billable_minutes)
worked_date = payload.get("worked_date")
if not worked_date:
if start_tid:
worked_date = start_tid.date()
elif slut_tid:
worked_date = slut_tid.date()
not_placed = bool(payload.get("ikke_placeret", False)) or (not start_tid and not slut_tid)
entry_status = payload.get("entry_status") or "afventer"
legacy_status = _legacy_status_from_entry_status(entry_status)
query = """
INSERT INTO tmodule_times (
sag_id, solution_id, customer_id, description,
original_hours, worked_date, user_name,
status, billable, billing_method,
start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
entry_type, kilde, entry_status, medarbejder_id,
aktiv_timer, round_block_min, ikke_placeret,
approved_hours, rounded_to
) VALUES (
%s, %s, %s, %s,
%s, %s, %s,
%s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s,
%s, %s
) RETURNING *
"""
inserted = execute_query(
query,
(
sag_id,
payload.get("solution_id"),
payload.get("customer_id"),
payload.get("beskrivelse") or payload.get("description"),
max(actual_minutes / 60.0, 0.01),
worked_date,
payload.get("user_name") or default_user_name,
legacy_status,
billable,
payload.get("billing_method") or ("invoice" if billable else "internal"),
start_tid,
slut_tid,
actual_minutes,
billable_minutes,
payload.get("entry_type") or "manuel",
payload.get("kilde") or "manuel",
entry_status,
bruger_id,
False,
round_block_min,
not_placed,
(billable_minutes / 60.0) if billable else 0,
(round_block_min / 60.0) if billable else None,
)
)
return inserted[0] if inserted else None
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error creating manual time entry: %s", e)
raise HTTPException(status_code=500, detail="Failed to create manual entry")
@router.patch("/time/{time_id}", tags=["Internal"])
async def patch_time_entry_v1(
time_id: int,
payload: Dict[str, Any] = Body(...)
):
"""Patch udvalgte felter på tidsentry. Faktisk tid ændres kun via start/slut."""
try:
existing = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
if not existing:
raise HTTPException(status_code=404, detail="Time entry not found")
updates: Dict[str, Any] = {}
allowed_direct = [
"description", "entry_type", "kilde", "entry_status", "billable", "worked_date",
"fakturerbar_tid_min", "round_block_min", "ikke_placeret", "medarbejder_id"
]
for key in allowed_direct:
if key in payload:
updates[key] = payload.get(key)
start_tid = _parse_iso_datetime(payload.get("start_tid")) if "start_tid" in payload else existing.get("start_tid")
slut_tid = _parse_iso_datetime(payload.get("slut_tid")) if "slut_tid" in payload else existing.get("slut_tid")
if "start_tid" in payload:
updates["start_tid"] = start_tid
if "slut_tid" in payload:
updates["slut_tid"] = slut_tid
recalculated_minutes = _minutes_between(start_tid, slut_tid)
if recalculated_minutes is not None:
updates["faktisk_tid_min"] = recalculated_minutes
updates["original_hours"] = max(recalculated_minutes / 60.0, 0.01)
if "fakturerbar_tid_min" not in updates:
block = int(updates.get("round_block_min") or existing.get("round_block_min") or 30)
updates["fakturerbar_tid_min"] = _round_up_minutes(recalculated_minutes, block)
if "entry_status" in updates:
updates["status"] = _legacy_status_from_entry_status(updates["entry_status"])
if "billable" in updates and not updates.get("billable"):
updates["fakturerbar_tid_min"] = 0
if "fakturerbar_tid_min" in updates:
billable_minutes = int(updates.get("fakturerbar_tid_min") or 0)
updates["approved_hours"] = billable_minutes / 60.0 if (updates.get("billable", existing.get("billable")) and billable_minutes > 0) else 0
if not updates:
return existing
set_parts = []
values: List[Any] = []
for field, value in updates.items():
set_parts.append(f"{field} = %s")
values.append(value)
values.append(time_id)
query = f"UPDATE tmodule_times SET {', '.join(set_parts)} WHERE id = %s RETURNING *"
updated = execute_query(query, tuple(values))
return updated[0] if updated else None
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error patching time entry %s: %s", time_id, e)
raise HTTPException(status_code=500, detail="Failed to patch time entry")
@router.post("/time/{time_id}/approve", tags=["Internal"])
async def approve_time_entry_v1(
time_id: int,
payload: Dict[str, Any] = Body(default={}),
current_user: Optional[dict] = Depends(get_optional_user)
):
"""Approve time entry for billing (kræver type)."""
try:
entry = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
entry_type = payload.get("entry_type") or entry.get("entry_type") or "ukendt"
if entry_type == "ukendt":
raise HTTPException(status_code=400, detail="entry_type is required before approval")
billable = bool(payload.get("fakturerbar", entry.get("billable", True)))
billed_minutes = payload.get("fakturerbar_tid_min")
if billed_minutes is None:
billed_minutes = entry.get("fakturerbar_tid_min")
if billed_minutes is None:
faktisk = int(entry.get("faktisk_tid_min") or 0)
billed_minutes = _round_up_minutes(faktisk, int(entry.get("round_block_min") or 30))
billed_minutes = int(billed_minutes)
approved_by = _resolve_current_user_id(current_user)
updated = execute_query(
"""
UPDATE tmodule_times
SET entry_type = %s,
entry_status = 'godkendt',
status = 'approved',
billable = %s,
fakturerbar_tid_min = CASE WHEN %s THEN %s ELSE 0 END,
approved_hours = CASE WHEN %s THEN (%s::numeric / 60.0) ELSE 0 END,
approved_at = %s,
approved_by = %s,
aktiv_timer = FALSE
WHERE id = %s
RETURNING *
""",
(
entry_type,
billable,
billable,
billed_minutes,
billable,
billed_minutes,
datetime.now(),
approved_by,
time_id,
)
)
return updated[0] if updated else None
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error approving time entry %s: %s", time_id, e)
raise HTTPException(status_code=500, detail="Failed to approve time entry")
@router.get("/entries/sag/{sag_id}", tags=["Internal"]) @router.get("/entries/sag/{sag_id}", tags=["Internal"])
async def get_time_entries_for_sag(sag_id: int): async def get_time_entries_for_sag(sag_id: int):
"""Get time entries linked to a Hub Sag (Case).""" """Get time entries linked to a Hub Sag (Case)."""
@ -1790,21 +2287,37 @@ async def create_internal_time_entry(
description = entry.get("description") description = entry.get("description")
hours = entry.get("original_hours") hours = entry.get("original_hours")
worked_date = entry.get("worked_date") or datetime.now().date() worked_date = entry.get("worked_date") or datetime.now().date()
start_tid = _parse_iso_datetime(entry.get("start_tid"))
slut_tid = _parse_iso_datetime(entry.get("slut_tid"))
default_user_name = ( default_user_name = (
(current_user or {}).get("username") (current_user or {}).get("username")
or (current_user or {}).get("full_name") or (current_user or {}).get("full_name")
or "Hub User" or "Hub User"
) )
user_name = entry.get("user_name") or default_user_name user_name = entry.get("user_name") or default_user_name
medarbejder_id = _resolve_current_user_id(current_user) or entry.get("medarbejder_id")
prepaid_card_id = entry.get("prepaid_card_id") prepaid_card_id = entry.get("prepaid_card_id")
fixed_price_agreement_id = entry.get("fixed_price_agreement_id") fixed_price_agreement_id = entry.get("fixed_price_agreement_id")
work_type = entry.get("work_type", "support") work_type = entry.get("work_type", "support")
is_internal = entry.get("is_internal", False) is_internal = entry.get("is_internal", False)
entry_type = entry.get("entry_type", "manuel")
kilde = entry.get("kilde", "manuel")
entry_status = entry.get("entry_status", "afventer")
round_block_min = int(entry.get("round_block_min") or 30)
if not sag_id or not hours: if not sag_id or not hours:
raise HTTPException(status_code=400, detail="sag_id and original_hours required") raise HTTPException(status_code=400, detail="sag_id and original_hours required")
hours_decimal = float(hours) hours_decimal = float(hours)
actual_minutes = entry.get("faktisk_tid_min")
if actual_minutes is None:
actual_minutes = _minutes_between(start_tid, slut_tid)
if actual_minutes is None:
actual_minutes = int(round(hours_decimal * 60))
billable_minutes = entry.get("fakturerbar_tid_min")
if billable_minutes is None:
billable_minutes = _round_up_minutes(actual_minutes, round_block_min)
# Auto-resolve customer if missing # Auto-resolve customer if missing
if not customer_id: if not customer_id:
@ -1985,12 +2498,18 @@ async def create_internal_time_entry(
sag_id, solution_id, customer_id, description, sag_id, solution_id, customer_id, description,
original_hours, worked_date, user_name, original_hours, worked_date, user_name,
status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type, status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type,
approved_hours, rounded_to approved_hours, rounded_to,
start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
entry_type, kilde, entry_status, medarbejder_id,
aktiv_timer, round_block_min, ikke_placeret
) VALUES ( ) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s
) RETURNING * ) RETURNING *
""" """
@ -1998,7 +2517,10 @@ async def create_internal_time_entry(
sag_id, solution_id, customer_id, description, sag_id, solution_id, customer_id, description,
hours, worked_date, user_name, hours, worked_date, user_name,
status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type, status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type,
entry.get('approved_hours'), entry.get('rounded_to') entry.get('approved_hours'), entry.get('rounded_to'),
start_tid, slut_tid, actual_minutes, billable_minutes,
entry_type, kilde, entry_status, medarbejder_id,
False, round_block_min, bool(entry.get("ikke_placeret", False) or (not start_tid and not slut_tid))
) )
result = execute_query(query, params) result = execute_query(query, params)
if result: if result:

View File

@ -0,0 +1,110 @@
-- Migration 150: Sag tidsforbrug v1 foundation
-- Formål: Udvide tmodule_times med felter til live timer, faktisk/fakturerbar minutter,
-- status-flow og type/kilde uden at bryde eksisterende timetracking-flow.
ALTER TABLE tmodule_times
ADD COLUMN IF NOT EXISTS start_tid TIMESTAMP,
ADD COLUMN IF NOT EXISTS slut_tid TIMESTAMP,
ADD COLUMN IF NOT EXISTS faktisk_tid_min INTEGER,
ADD COLUMN IF NOT EXISTS fakturerbar_tid_min INTEGER,
ADD COLUMN IF NOT EXISTS entry_type VARCHAR(32) DEFAULT 'ukendt',
ADD COLUMN IF NOT EXISTS kilde VARCHAR(32) DEFAULT 'manuel',
ADD COLUMN IF NOT EXISTS entry_status VARCHAR(32) DEFAULT 'afventer',
ADD COLUMN IF NOT EXISTS medarbejder_id INTEGER,
ADD COLUMN IF NOT EXISTS aktiv_timer BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS round_block_min INTEGER DEFAULT 30,
ADD COLUMN IF NOT EXISTS ikke_placeret BOOLEAN DEFAULT FALSE;
-- Optional settings per customer/type (fx mail default minutter)
CREATE TABLE IF NOT EXISTS tmodule_time_defaults (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES tmodule_customers(id) ON DELETE CASCADE,
entry_type VARCHAR(32) NOT NULL,
default_minutes INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT tmodule_time_defaults_default_minutes_positive CHECK (default_minutes > 0),
CONSTRAINT tmodule_time_defaults_unique UNIQUE (customer_id, entry_type)
);
-- Backfill for existing rows so gamle data stadig virker i ny UI/API.
UPDATE tmodule_times
SET
entry_type = COALESCE(entry_type, 'ukendt'),
kilde = COALESCE(kilde, 'api'),
entry_status = COALESCE(
entry_status,
CASE
WHEN status = 'approved' THEN 'godkendt'
WHEN status = 'pending' THEN 'afventer'
WHEN status = 'billed' THEN 'godkendt'
ELSE 'kladde'
END
),
faktisk_tid_min = COALESCE(faktisk_tid_min, CEIL(COALESCE(original_hours, 0)::numeric * 60)::int),
fakturerbar_tid_min = COALESCE(
fakturerbar_tid_min,
CEIL(COALESCE(approved_hours, original_hours, 0)::numeric * 60)::int
),
round_block_min = COALESCE(round_block_min, 30),
aktiv_timer = COALESCE(aktiv_timer, FALSE),
ikke_placeret = COALESCE(ikke_placeret, FALSE);
-- Guards
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tmodule_times_faktisk_tid_min_positive'
AND conrelid = 'tmodule_times'::regclass
) THEN
ALTER TABLE tmodule_times
ADD CONSTRAINT tmodule_times_faktisk_tid_min_positive CHECK (faktisk_tid_min IS NULL OR faktisk_tid_min >= 0);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tmodule_times_fakturerbar_tid_min_positive'
AND conrelid = 'tmodule_times'::regclass
) THEN
ALTER TABLE tmodule_times
ADD CONSTRAINT tmodule_times_fakturerbar_tid_min_positive CHECK (fakturerbar_tid_min IS NULL OR fakturerbar_tid_min >= 0);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tmodule_times_entry_status_check'
AND conrelid = 'tmodule_times'::regclass
) THEN
ALTER TABLE tmodule_times
ADD CONSTRAINT tmodule_times_entry_status_check CHECK (entry_status IN ('kladde', 'afventer', 'godkendt'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tmodule_times_entry_type_check'
AND conrelid = 'tmodule_times'::regclass
) THEN
ALTER TABLE tmodule_times
ADD CONSTRAINT tmodule_times_entry_type_check CHECK (entry_type IN ('opkald', 'mail', 'indedesk', 'manuel', 'ukendt'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tmodule_times_kilde_check'
AND conrelid = 'tmodule_times'::regclass
) THEN
ALTER TABLE tmodule_times
ADD CONSTRAINT tmodule_times_kilde_check CHECK (kilde IN ('auto', 'manuel', 'api'));
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_tmodule_times_start_tid ON tmodule_times(start_tid);
CREATE INDEX IF NOT EXISTS idx_tmodule_times_medarbejder_id ON tmodule_times(medarbejder_id);
CREATE INDEX IF NOT EXISTS idx_tmodule_times_entry_status ON tmodule_times(entry_status);
CREATE INDEX IF NOT EXISTS idx_tmodule_times_aktiv_timer ON tmodule_times(aktiv_timer);
CREATE INDEX IF NOT EXISTS idx_tmodule_time_defaults_customer ON tmodule_time_defaults(customer_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_tmodule_times_active_timer_per_user
ON tmodule_times(medarbejder_id)
WHERE aktiv_timer = TRUE AND slut_tid IS NULL;