hour-tracker/front-end/src/components/EntryTable.vue

317 lines
9.1 KiB
Vue

<script setup lang="ts">
import type PocketBase from "pocketbase";
import Button from "primevue/button";
import Column from "primevue/column";
import DataTable, { type DataTableRowEditSaveEvent } from "primevue/datatable";
import DatePicker from "primevue/datepicker";
import InputText from "primevue/inputtext";
import Select from "primevue/select";
import Tag from "primevue/tag";
import type { Entry, EntryDatabase, Project } from "src/types";
import { computed, inject, onMounted, ref, toRef, watch } from "vue";
// biome-ignore lint/suspicious/noExplicitAny: the whole idea is to assert any type
function assertType(variable: any, typeNames: string[]) {
const observedType = typeof variable;
for (const typeName of typeNames) {
if (observedType === typeName) {
return;
}
}
throw new Error(`expected ${typeNames.join("|")}, observed ${observedType}`);
}
const pb = inject("pb") as PocketBase;
const props = defineProps<{
org: string;
date: Date;
projects: Project[];
}>();
const editingRows = ref([]);
const entries = ref<Entry[]>([]);
const newEntrySelection = ref("");
const watchDate = toRef(() => props.date);
const watchOrganisation = toRef(() => props.org);
watch(watchDate, () => {
getEntries().then();
});
watch(watchOrganisation, () => {
getEntries().then();
});
onMounted(async () => {
await getEntries();
});
const summaries = computed(() => {
const e = entries.value.filter((el) => {
return el.end !== null && el.end.getTime() > el.start.getTime() && el.project !== "";
});
const minutes: { [key: string]: number } = {};
for (const el of e) {
if (el.end !== null) {
const pid = el.project;
if (!(pid in minutes)) {
minutes[pid] = 0;
}
const end = new Date(el.end).getTime();
const start = new Date(el.start).getTime();
const elapsed = Math.round((end - start) / (1000 * 60));
minutes[pid] = minutes[pid] + elapsed;
}
}
const result: { key: string; start: Date; end: Date }[] = [];
let cumulative_sum = 0;
for (const key in minutes) {
const start = new Date(props.date.getTime());
start.setHours(8, 0, 0);
start.setMinutes(start.getMinutes() + cumulative_sum);
cumulative_sum += minutes[key];
const end = new Date(props.date.getTime());
end.setHours(8, 0, 0);
end.setMinutes(end.getMinutes() + cumulative_sum);
result.push({ key, start, end });
}
return result;
});
const isEditorActive = computed(() => {
return editingRows.value.length > 0;
});
async function getEntries() {
entries.value = [];
const begin = new Date(props.date.getTime());
begin.setHours(0, 0, 0);
const end = new Date(props.date.getTime());
end.setHours(23, 59, 59);
const filter = pb.filter(
"{:begin} <= start && start <= {:end} && project.organisation = {:organisation}",
{ begin, end, organisation: props.org },
);
const recordsDatabase = await pb.collection<EntryDatabase>("entries").getFullList(500, {
filter,
sort: "start",
});
const records = recordsDatabase.map((el) => {
return {
id: el.id,
project: el.project,
start: new Date(el.start),
end: el.end === "" ? null : new Date(el.end),
description: el.description,
};
});
entries.value = [...records];
}
async function endNow(index: number) {
const end = new Date(entries.value[index].start);
const now = new Date();
end.setHours(now.getHours());
end.setSeconds(0, 0);
const minutes = now.getMinutes();
if (minutes > 0 && minutes < 30) {
end.setMinutes(30);
} else if (minutes > 30 && minutes < 60) {
end.setMinutes(60);
}
entries.value[index].end = end;
if (entries.value[index].id !== "") {
await pb.collection<Entry>("entries").update(entries.value[index].id, { end });
}
}
function getDateFmt(date: Date | null): string {
assertType(date, ["object", "null"]);
if (date === null) {
return "";
}
const year = date.getFullYear();
const month = `0${date.getMonth()}`.slice(-2);
const date_str = date.getDate();
const hour = `0${date.getHours()}`.slice(-2);
const minute = `0${date.getMinutes()}`.slice(-2);
return `${date_str}/${month}/${year} ${hour}:${minute}`;
}
async function save(event: DataTableRowEditSaveEvent) {
const { newData, index } = event;
const bodyParams = {
project: newData.project,
start: newData.start,
end: newData.end ?? "",
description: newData.description,
user: newData.user,
};
if (newData.id === "") {
const entry = await pb.collection<Entry>("entries").create(bodyParams);
newData.id = entry.id;
} else {
await pb.collection<Entry>("entries").update(newData.id, bodyParams);
}
entries.value[index] = newData;
}
async function deleteEntry(index: number, entry: Entry) {
if (confirm("Confirm entry deletion")) {
if (entry.id !== "") {
await pb.collection<Entry>("entries").delete(entry.id);
}
entries.value.splice(index, 1);
}
}
async function newEntry() {
const start = new Date(props.date.getTime());
start.setSeconds(0, 0);
const entry = {
id: "",
project: newEntrySelection.value,
start,
end: null,
description: "",
user: pb.authStore.model !== null ? pb.authStore.model.id : "",
};
entries.value.push(entry);
}
function getProjectById(id: string): Project {
const item = props.projects.find((p) => p.id === id);
if (item !== undefined) {
return item;
}
throw Error(`unknown project id ${id}`);
}
function projectTitleFormat(project: Project, titleLimit: number): string {
if (project.name.length > 3) {
const name = project.name;
const trimmedName = name.length > titleLimit ? `${name.slice(0, titleLimit - 3)}...` : name;
const title = `(${project.identifier}) ${trimmedName}`;
return title;
}
return project.name;
}
</script>
<template>
<DataTable
:value="entries"
edit-mode="row"
v-model:editingRows="editingRows"
data-key="id"
@row-edit-save="save"
>
<template #empty>
No entries
</template>
<Column :row-editor="true" :exportable="false"></Column>
<Column field="project">
<template #body="slotProps">
{{ projectTitleFormat(getProjectById(slotProps.data.project), 10) }}
</template>
<template #header>
<Select
:options="projects"
:option-label="(item: Project) => `${item.name} (${item.identifier})`"
v-model="newEntrySelection"
optionValue="id"
placeholder="Add"
@change="newEntry"
/>
</template>
<template #editor="{ data, field }">
<Select
:options="projects"
:option-label="(item: Project) => `${item.name} (${item.identifier})`"
optionValue="id"
v-model="data[field]"
placeholder="Project"
/>
</template>
</Column>
<Column field="start" header="Start">
<template #body="slotProps">
{{ getDateFmt(slotProps.data.start) }}
</template>
<template #editor="{ data, field }">
<DatePicker v-model="data[field]" showTime style="min-width: 100px;" />
</template>
</Column>
<Column field="end" header="End">
<template #body="slotProps">
{{ getDateFmt(slotProps.data.end) }}
</template>
<template #editor="{ data, field }">
<DatePicker v-if="data[field]" v-model="data[field]" showTime style="min-width: 100px;" />
</template>
</Column>
<Column field="description" header="Description">
<template #editor="{ data, field }">
<InputText v-model="data[field]" />
</template>
</Column>
<Column header="" :exportable="false">
<template #body="slotProps">
<Button label="End Now" text @click="endNow(slotProps.index)" v-if="!isEditorActive" />
<Button label="Delete" text @click="deleteEntry(slotProps.index, slotProps.data)" />
</template>
</Column>
<Column header="" :exportable="false">
<template #body="slotProps">
<div style="display: flex; justify-content: center; align-items: center;">
<Tag severity="danger" value="Unsaved" v-if="slotProps.data.id === ''"></Tag>
</div>
</template>
</Column>
</DataTable>
<DataTable v-if="summaries.length > 0" :value="summaries">
<Column header="Project">
<template #body="slotProps">
{{ projectTitleFormat(getProjectById(slotProps.data.key), 60) }}
</template>
</Column>
<Column header="Start">
<template #body="slotProps">
{{ getDateFmt(slotProps.data.start) }}
</template>
</Column>
<Column header="End">
<template #body="slotProps">
{{ getDateFmt(slotProps.data.end) }}
</template>
</Column>
</DataTable>
</template>