280 lines
8.9 KiB
Svelte
280 lines
8.9 KiB
Svelte
<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 docLinks = $state(null);
|
||
let router = useTinyRouter();
|
||
let times = $state(null);
|
||
let tasks = {};
|
||
let projects = {};
|
||
let detail = $state(null);
|
||
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));
|
||
|
||
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;
|
||
} else {
|
||
error(resp);
|
||
}
|
||
}
|
||
|
||
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('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))}>
|
||
{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 && 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="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}
|