|
|
<script> |
|
|
import { onMount } from 'svelte'; |
|
|
import { useTinyRouter } from 'svelte-tiny-router'; |
|
|
import { api } 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 tasks = {}; |
|
|
let projects = {}; |
|
|
let project_filter = $state(null); |
|
|
if (router.hasQueryParam('project')) project_filter = router.getQueryParam('project'); |
|
|
let sortedTimes = $derived.by(() => Object.values(times).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; |
|
|
|
|
|
async function addTime(task_id){ |
|
|
const url = api(`time/track_task/${task_id}`); |
|
|
const resp = await fetch(url,{ |
|
|
credentials : 'include', |
|
|
method : 'POST', |
|
|
body : now() |
|
|
}); // create new time or return time with assigned tasks |
|
|
if (resp.ok) { |
|
|
const track = await resp.json(); |
|
|
timetrack.running = track; |
|
|
} else { |
|
|
error(resp); |
|
|
} |
|
|
} |
|
|
|
|
|
function calcYearMap(){ |
|
|
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 fetch(url,{ |
|
|
credentials : 'include', |
|
|
method : 'POST', |
|
|
body : `${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 fetch(url,{credentials:'include'}); |
|
|
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 (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 fetch(url,{ |
|
|
credentials:'include', |
|
|
method:'DELETE' |
|
|
}); |
|
|
if (res.ok){ |
|
|
delete times[time_id]; |
|
|
yikes(); |
|
|
} else { |
|
|
error(res); |
|
|
} |
|
|
} |
|
|
|
|
|
function openProject(pid){ |
|
|
router.navigate(`/project/${pid}/view`); |
|
|
} |
|
|
|
|
|
function openTask(tid){ |
|
|
router.navigate(`/task/${tid}/view`); |
|
|
} |
|
|
|
|
|
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)} {t('hours')}</span> |
|
|
</div> |
|
|
{/if} |
|
|
<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} |
|
|
{#if match_prj_filter(time)} |
|
|
<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))}> |
|
|
{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))}> |
|
|
{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)} 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> |
|
|
{/if} |
|
|
{/each} |
|
|
</tbody> |
|
|
</table> |
|
|
{/if}
|
|
|
|