Files
Umbrella/frontend/src/routes/time/Index.svelte
T
StephanRichter bcc1182dea
Build Docker Image / Docker-Build (push) Successful in 2m25s
Build Docker Image / Clean-Registry (push) Failing after 10m18s
improved time table: now updating started time when starting new time track
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-06-17 09:14:57 +02:00

336 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router';
import { api, drop, get, patch, post } from '../../urls.svelte.js';
import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte.js';
import { timetrack } from '../../user.svelte.js';
import { display } from '../../time.svelte.js';
import { now } from '../../time.svelte';
import TimeEditor from '../../Components/TimeRecordEditor.svelte';
let detail = $state(null);
let docLinks = $state(null);
let router = useTinyRouter();
let times = $state(null);
let closed = $state(false);
let tasks = {};
let projects = {};
let project_filter = $state(null);
if (router.hasQueryParam('project')) project_filter = router.getQueryParam('project');
let sortedTimes = $derived.by(() => Object.values(times).filter(match_prj_filter).map(time => ({
...time,
start: display(time.start_time),
end: display(time.end_time),
end_date: display(time.end_time)?.substring(0,10)
})).sort((b, a) => a.start_time - b.start_time));
let selected = $state({});
let ranges = {};
let timeMap = $derived.by(calcYearMap);
let selectionSum = $derived(sortedTimes.filter(time => selected[time.id]).map(time => time.duration).reduce((acc, a) => acc + a, 0));
let users = null;
$effect(() => {
if (timetrack.running) {
show(timetrack.running);
} else {
show(timetrack.stopped);
}
});
async function addTime(task_id){
let url = api(`time/track_task/${task_id}`);
let resp = await post(url,now()); // create new time or return time with assigned tasks
if (resp.ok) {
const track = await resp.json();
if (timetrack.running){
url = api(`time/${timetrack.running.id}`);
resp = await get(url);
if (resp.ok){
let previous = await resp.json();
times[previous.id] = previous;
}
}
timetrack.running = track;
} else {
error(resp);
}
}
function calcYearMap(){
console.log('calcYearMap called');
let result = {
months : {},
years : {}
}
let lastYear = null;
let lastMonth = null;
let yearCount = 0;
let monthCount = 0;
let yearIndex = 0;
let monthIndex = 0;
for (let idx in sortedTimes){
const time = sortedTimes[idx];
const year = time.start.substring(0,4);
const month = time.start.substring(5,7);
if (year != lastYear){
lastYear = year;
if (yearCount) result.years[yearIndex] = yearCount;
yearCount = 0;
yearIndex = idx;
}
yearCount +=1;
if (month != lastMonth){
lastMonth = month;
if (monthCount) result.months[monthIndex] = monthCount;
monthCount = 0;
monthIndex = idx;
}
monthCount +=1;
}
if (yearCount) result.years[yearIndex] = yearCount;
if (monthCount) result.months[monthIndex] = monthCount;
return result;
}
async function joinTimes(evt, id1, id2){
evt.preventDefault();
evt.stopPropagation();
const url = api('time/join');
const res = await post(url,`${id1}+${id2}`);
if (res.ok){
yikes();
let json = await res.json();
delete times[id1];
delete times[id2];
times[json.id] = json;
} else {
error(res);
}
return false;
}
async function loadTimes(){
const url = api('time');
const resp = await post(url,{closed:closed});
if (resp.ok){
var json = await resp.json();
times = json.times;
tasks = json.tasks;
projects = json.projects;
docLinks = json.documents;
users = json.users;
} else {
error(resp);
}
}
function match_prj_filter(time){
if (!project_filter) return true;
for (var tid of time.task_ids){
if (tasks[tid] && project_filter == tasks[tid].project_id) return true;
}
return false;
}
function onAbort(){
detail = null;
}
function onclick(e){
e.preventDefault();
let href = e.target.getAttribute('href');
if (href) router.navigate(href);
return false;
}
async function onDrop(time_id){
const url = api(`time/${time_id}`);
const res = await drop(url);
if (res.ok){
delete times[time_id];
yikes();
} else {
error(res);
}
}
async function multi_update(changeSet){
changeSet.ids = Object.keys(selected).map(id => +id);
const url = api('time');
const res = await patch(url,changeSet);
if (res.ok){
yikes();
var updated = await res.json();
times = {...times, ...updated};
} else error(res);
}
function openProject(pid){
router.navigate(`/project/${pid}/view`);
}
function openTask(tid){
router.navigate(`/task/${tid}/view`);
}
function reload(ev){
loadTimes();
}
function show(track){
if (times && track) times[track.id] = track;
}
function toggleRange(range){
let affected = sortedTimes.filter(time => time.start.startsWith(range));
if (ranges[range]){
delete ranges[range];
for (let time of affected){
if (selected[time.id]) delete selected[time.id]
}
} else {
for (let time of affected) selected[time.id] = true;
ranges[range] = true;
}
}
function toggleSelect(time_id){
detail = null;
if (selected[time_id]) {
delete selected[time_id]
} else selected[time_id] = true;
}
async function update(time){
const url = api(`time/${time.id}`);
const res = await fetch(url,{
credentials:'include',
method:'PATCH',
body:JSON.stringify(time)
});
if (res.ok){
let json = await res.json();
let id = json.id;
for (let key of Object.keys(json)) times[id][key] = json[key];
detail = null;
yikes();
return true;
} else {
error(res);
return false;
}
}
onMount(loadTimes);
</script>
<svelte:head>
<title>Umbrella {t('timetracking')}</title>
</svelte:head>
<h1>{t('timetracking')}</h1>
{#if times}
{#if selectionSum}
<div class="timetracks sum">
{t('sum_of_records')}: <span>{selectionSum.toFixed(3)}&nbsp;{t('hours')}</span>
<button class="symbol" title={t('open')} onclick={e => multi_update({state:'Open'})} ></button>
<button class="symbol" title={t('pending')} onclick={e => multi_update({state:'Pending'})} ></button>
<button class="symbol" title={t('complete')} onclick={e => multi_update({state:'Complete'})} ></button>
</div>
{/if}
<div class="switch">
<label>
<input type="checkbox" bind:checked={closed} onchange={reload} />
{t('show_closed')}
</label>
</div>
<table class="timetracks">
<thead>
<tr>
<th>{t('year')}</th>
<th>{t('month')}</th>
<th>{t('start')}<wbr><wbr>{t('end')}</th>
<th>{t('duration')}</th>
<th>{t('user')}</th>
<th>{t('subject')}</th>
<th>{t('projects')} / {t('tasks')}</th>
<th>{t('state')}</th>
</tr>
</thead>
<tbody>
{#each sortedTimes as time,line}
<tr class={selected[time.id]?'selected':''}>
{#if timeMap.years[line]}
<td class="year" rowspan={timeMap.years[line]} onclick={e => toggleRange(time.start.substring(0,4))} title={time.start.substring(0,4)} >
{time.start.substring(0,4)}
</td>
{/if}
{#if timeMap.months[line]}
<td class="month" rowspan={timeMap.months[line]} onclick={e => toggleRange(time.start.substring(0,7))} title={time.start.substring(5,7)} >
{time.start.substring(5,7)}
</td>
{/if}
{#if detail == time.id}
<td colspan="5">
<TimeEditor record={time} {onAbort} {onDrop} onSet={update} />
</td>
{:else}
<td class="start_end" onclick={e => toggleSelect(time.id)}>
{time.start}{#if time.end_time}<wbr><wbr>{time.start.startsWith(time.end_date)?time.end.substring(11):time.end}{/if}
{#if line>0 && (sortedTimes[line-1].user_id == time.user_id) && (Math.abs(sortedTimes[line-1].start_time - time.end_time)<100)}
<button class="symbol join" title={t('join_objects',{objects:t('times')})} onclick={e => joinTimes(e, time.id, sortedTimes[line-1].id)} ></button>
{/if}
</td>
<td class="duration" onclick={e => {detail = time.id}}>
{#if time.duration}
{time.duration.toFixed(3)}&nbsp;h
{/if}
</td>
<td class="user" onclick={e => {detail = time.id}}>
{users[time.user_id].name}
</td>
<td class="subject" onclick={e => {detail = time.id}}>
{time.subject}
</td>
<td class="tasks">
<ul>
{#each time.task_ids as tid}
{#if tasks[tid]}
<li>
{#if tasks[tid] && projects[tasks[tid].project_id]}
<a href="/project/{tasks[tid].project_id}/view" {onclick}>{projects[tasks[tid].project_id].name}</a> /
{/if}
<a href="/task/{tid}/view" {onclick}>{tasks[tid].name}</a>
<button class="symbol" title={t('timetracking')} onclick={e => addTime(tid)}></button>
</li>
{/if}
{/each}
</ul>
</td>
<td class="state" onclick={e => {detail = time.id}}>
{t("state_"+time.state.name.toLowerCase())}
{#if time.state.name.toLowerCase() == 'pending' && docLinks[time.id]}
<ul>
{#each Object.entries(docLinks[time.id]) as [a,b]}
<li>
<a href="/document/{a}/view" {onclick}>{b}</a>
</li>
{/each}
</ul>
{/if}
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
{/if}