317 lines
9.1 KiB
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>
|
|
|