Files
Umbrella/frontend/src/routes/time/Index.svelte

280 lines
8.9 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 } 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)}&nbsp;{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)}&nbsp;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}