feat(timetracking): start sag tidsforbrug v1 backend+ui
This commit is contained in:
parent
43fd651723
commit
205c0dab07
@ -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 }};
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
110
migrations/150_sag_tidsforbrug_v1.sql
Normal file
110
migrations/150_sag_tidsforbrug_v1.sql
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user