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
|
||||
</button>
|
||||
</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">
|
||||
<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
|
||||
@ -2799,6 +2804,7 @@
|
||||
'wiki': 'Wiki',
|
||||
'todo-steps': 'Todo-opgaver',
|
||||
'time': 'Tid',
|
||||
'timetracking': 'Tidsforbrug',
|
||||
'solution': 'Løsning',
|
||||
'sales': 'Varekøb & salg',
|
||||
'subscription': 'Abonnement',
|
||||
@ -2877,6 +2883,8 @@
|
||||
try {
|
||||
if (tabId === 'sales' && typeof loadVarekobSalg === 'function') {
|
||||
await loadVarekobSalg();
|
||||
} else if (tabId === 'timetracking' && typeof loadTimeTrackingTab === 'function') {
|
||||
await loadTimeTrackingTab();
|
||||
} else if (tabId === 'subscription' && typeof loadSubscriptionForCase === 'function') {
|
||||
await loadSubscriptionForCase();
|
||||
} else if (tabId === 'reminders') {
|
||||
@ -5007,6 +5015,100 @@
|
||||
</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 -->
|
||||
<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">
|
||||
@ -6186,6 +6288,185 @@
|
||||
});
|
||||
</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>
|
||||
let reminderUserId = null;
|
||||
const remindersCaseId = {{ case.id }};
|
||||
|
||||
@ -95,6 +95,17 @@ class TModuleTimeBase(BaseModel):
|
||||
original_hours: Decimal = Field(..., gt=0, description="Original timer")
|
||||
worked_date: Optional[date] = None
|
||||
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):
|
||||
@ -110,6 +121,9 @@ class TModuleTimeUpdate(BaseModel):
|
||||
billable: Optional[bool] = None
|
||||
is_travel: Optional[bool] = None
|
||||
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):
|
||||
|
||||
@ -52,6 +52,51 @@ logger = logging.getLogger(__name__)
|
||||
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
|
||||
# ============================================================================
|
||||
@ -1758,6 +1803,458 @@ async def uninstall_module(
|
||||
# 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"])
|
||||
async def get_time_entries_for_sag(sag_id: int):
|
||||
"""Get time entries linked to a Hub Sag (Case)."""
|
||||
@ -1790,21 +2287,37 @@ async def create_internal_time_entry(
|
||||
description = entry.get("description")
|
||||
hours = entry.get("original_hours")
|
||||
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 = (
|
||||
(current_user or {}).get("username")
|
||||
or (current_user or {}).get("full_name")
|
||||
or "Hub User"
|
||||
)
|
||||
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")
|
||||
fixed_price_agreement_id = entry.get("fixed_price_agreement_id")
|
||||
work_type = entry.get("work_type", "support")
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="sag_id and original_hours required")
|
||||
|
||||
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
|
||||
if not customer_id:
|
||||
@ -1985,12 +2498,18 @@ async def create_internal_time_entry(
|
||||
sag_id, solution_id, customer_id, description,
|
||||
original_hours, worked_date, user_name,
|
||||
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 (
|
||||
%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 *
|
||||
"""
|
||||
|
||||
@ -1998,7 +2517,10 @@ async def create_internal_time_entry(
|
||||
sag_id, solution_id, customer_id, description,
|
||||
hours, worked_date, user_name,
|
||||
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)
|
||||
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