Compare commits
7 commits
a96ac9fbe1
...
6498a181c8
| Author | SHA1 | Date | |
|---|---|---|---|
| 6498a181c8 | |||
| fea2964395 | |||
| a883a556be | |||
| 1ab7fbd12e | |||
| cbd520e999 | |||
| 03107fb305 | |||
| ff6804a6e7 |
5 changed files with 142 additions and 81 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -13,6 +13,7 @@ dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
.vscode
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,17 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
|
Project string
|
||||||
Start string
|
Start string
|
||||||
End string
|
End string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
Id string
|
||||||
|
Identifier string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := pocketbase.New()
|
app := pocketbase.New()
|
||||||
|
|
||||||
|
|
@ -28,6 +35,7 @@ func main() {
|
||||||
month := c.PathParam("month")
|
month := c.PathParam("month")
|
||||||
organisation := c.PathParam("organisation")
|
organisation := c.PathParam("organisation")
|
||||||
|
|
||||||
|
projects := []Project{}
|
||||||
entries := []Entry{}
|
entries := []Entry{}
|
||||||
|
|
||||||
record, _ := c.Get(apis.ContextAuthRecordKey).(*models.Record)
|
record, _ := c.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||||
|
|
@ -37,6 +45,20 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
err := app.Dao().DB().
|
err := app.Dao().DB().
|
||||||
|
NewQuery("SELECT * FROM projects WHERE organisation = {:organisation}").
|
||||||
|
Bind(dbx.Params{
|
||||||
|
"start": fmt.Sprintf("%s-%s-01 00:00:00.000Z", year, month),
|
||||||
|
"end": fmt.Sprintf("%s-%s-31 23:59:59.999Z", year, month),
|
||||||
|
"organisation": organisation,
|
||||||
|
"user": record.Id,
|
||||||
|
}).
|
||||||
|
All(&projects)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{"hours": nil, "error": err})
|
||||||
|
}
|
||||||
|
|
||||||
|
err = app.Dao().DB().
|
||||||
NewQuery("SELECT * FROM entries WHERE user = {:user} AND start >= {:start} AND end <= {:end} AND end != '' AND project IN (SELECT id FROM projects WHERE organisation = {:organisation})").
|
NewQuery("SELECT * FROM entries WHERE user = {:user} AND start >= {:start} AND end <= {:end} AND end != '' AND project IN (SELECT id FROM projects WHERE organisation = {:organisation})").
|
||||||
Bind(dbx.Params{
|
Bind(dbx.Params{
|
||||||
"start": fmt.Sprintf("%s-%s-01 00:00:00.000Z", year, month),
|
"start": fmt.Sprintf("%s-%s-01 00:00:00.000Z", year, month),
|
||||||
|
|
@ -47,25 +69,45 @@ func main() {
|
||||||
All(&entries)
|
All(&entries)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusOK, map[string]any{"hours": 0, "error": err})
|
return c.JSON(http.StatusOK, map[string]any{"hours": nil, "error": err})
|
||||||
}
|
}
|
||||||
|
|
||||||
hours := 0.0
|
var projects_map map[string]string
|
||||||
|
projects_map = make(map[string]string)
|
||||||
|
|
||||||
|
for _, e := range projects {
|
||||||
|
projects_map[e.Id] = fmt.Sprintf("(%s) %s", e.Identifier, e.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hours map[string]float64
|
||||||
|
hours = make(map[string]float64)
|
||||||
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
start, err := time.Parse("2006-01-02 15:04:05.000Z", e.Start)
|
start, err := time.Parse("2006-01-02 15:04:05.000Z", e.Start)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusOK, map[string]any{"hours": 0, "error": fmt.Sprintf("time.Parse(%s)", e.Start)})
|
return c.JSON(http.StatusOK, map[string]any{"hours": nil, "error": fmt.Sprintf("time.Parse(%s)", e.Start)})
|
||||||
}
|
}
|
||||||
|
|
||||||
end, err := time.Parse("2006-01-02 15:04:05.000Z", e.End)
|
end, err := time.Parse("2006-01-02 15:04:05.000Z", e.End)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusOK, map[string]any{"hours": 0, "error": fmt.Sprintf("time.Parse(%s)", e.End)})
|
return c.JSON(http.StatusOK, map[string]any{"hours": nil, "error": fmt.Sprintf("time.Parse(%s)", e.End)})
|
||||||
}
|
}
|
||||||
|
|
||||||
hours = hours + end.Sub(start).Hours()
|
var entry_hours = end.Sub(start).Hours()
|
||||||
|
|
||||||
|
project, project_exists := projects_map[e.Project]
|
||||||
|
|
||||||
|
if !project_exists {
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{"hours": nil, "error": fmt.Sprintf("time.Parse(%s)", e.End)})
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, total_entry_exists := hours[project]; total_entry_exists {
|
||||||
|
hours[project] = value + entry_hours
|
||||||
|
} else {
|
||||||
|
hours[project] = entry_hours
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]any{"hours": hours, "error": nil})
|
return c.JSON(http.StatusOK, map[string]any{"hours": hours, "error": nil})
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,13 @@ const date = ref(new Date());
|
||||||
const email = ref("");
|
const email = ref("");
|
||||||
const failedLogin = ref(false);
|
const failedLogin = ref(false);
|
||||||
const isLogged = ref(pb.authStore.isValid);
|
const isLogged = ref(pb.authStore.isValid);
|
||||||
const org = ref("");
|
const organisation = ref("");
|
||||||
const organisations = ref<Organisation[]>([]);
|
const organisations = ref<Organisation[]>([]);
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
const projects = ref<Project[]>([]);
|
const projects = ref<Project[]>([]);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
watch(org, async () => {
|
watch(organisation, async () => {
|
||||||
await getProjects();
|
await getProjects();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ async function logout() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getProjects() {
|
async function getProjects() {
|
||||||
const filter = pb.filter("organisation = {:organisation}", { organisation: org.value });
|
const filter = pb.filter("organisation = {:organisation}", { organisation: organisation.value });
|
||||||
|
|
||||||
const records = await pb.collection<Project>("projects").getFullList(500, {
|
const records = await pb.collection<Project>("projects").getFullList(500, {
|
||||||
filter,
|
filter,
|
||||||
|
|
@ -72,8 +72,8 @@ async function getProjects() {
|
||||||
async function getOrganisations() {
|
async function getOrganisations() {
|
||||||
const records = await pb.collection<Organisation>("organisations").getFullList();
|
const records = await pb.collection<Organisation>("organisations").getFullList();
|
||||||
organisations.value = [...records];
|
organisations.value = [...records];
|
||||||
if (org.value === "" && records.length > 0) {
|
if (organisation.value === "" && records.length > 0) {
|
||||||
org.value = records[0].id;
|
organisation.value = records[0].id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,13 +88,13 @@ function setDate(newDate: Date) {
|
||||||
<template #center>
|
<template #center>
|
||||||
<template v-if="isLogged">
|
<template v-if="isLogged">
|
||||||
<Select
|
<Select
|
||||||
v-model="org"
|
v-model="organisation"
|
||||||
:options="organisations"
|
:options="organisations"
|
||||||
optionLabel="name"
|
optionLabel="name"
|
||||||
optionValue="id"
|
optionValue="id"
|
||||||
placeholder="Organisation"
|
placeholder="Organisation"
|
||||||
/>
|
/>
|
||||||
<Controls @date="setDate" :projects="projects" :org="org" v-if="org !== ''" />
|
<Controls @date="setDate" :projects="projects" :org="organisation" v-if="organisation !== ''" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #end>
|
<template #end>
|
||||||
|
|
@ -109,9 +109,9 @@ function setDate(newDate: Date) {
|
||||||
</template>
|
</template>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<EntryTable
|
<EntryTable
|
||||||
:org="org"
|
:org="organisation"
|
||||||
:date="date"
|
:date="date"
|
||||||
:projects="projects"
|
:projects="projects"
|
||||||
v-if="isLogged && org !== ''"
|
v-if="isLogged && organisation !== ''"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,16 @@ import DatePicker from "primevue/datepicker";
|
||||||
import Dialog from "primevue/dialog";
|
import Dialog from "primevue/dialog";
|
||||||
import { inject, ref } from "vue";
|
import { inject, ref } from "vue";
|
||||||
|
|
||||||
const date = ref(new Date());
|
const modalTotalHours = ref<{
|
||||||
const totalHours = ref(0);
|
hours: { [project: string]: number };
|
||||||
const totalHoursVisible = ref(false);
|
total: number;
|
||||||
|
visible: boolean;
|
||||||
|
}>({
|
||||||
|
hours: {},
|
||||||
|
total: 0,
|
||||||
|
visible: false,
|
||||||
|
});
|
||||||
|
const selectedDate = ref(new Date());
|
||||||
|
|
||||||
const pb = inject("pb") as PocketBase;
|
const pb = inject("pb") as PocketBase;
|
||||||
const props = defineProps<{ org: string; projects: Project[] }>();
|
const props = defineProps<{ org: string; projects: Project[] }>();
|
||||||
|
|
@ -16,21 +23,29 @@ const props = defineProps<{ org: string; projects: Project[] }>();
|
||||||
const emits = defineEmits<{ date: [Date]; project: [string] }>();
|
const emits = defineEmits<{ date: [Date]; project: [string] }>();
|
||||||
|
|
||||||
function setDate(offset: number) {
|
function setDate(offset: number) {
|
||||||
date.value = new Date(date.value.setDate(date.value.getDate() + offset));
|
selectedDate.value = new Date(selectedDate.value.setDate(selectedDate.value.getDate() + offset));
|
||||||
emits("date", date.value);
|
emits("date", selectedDate.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showTotalHours() {
|
async function showModalTotalHours() {
|
||||||
const year = date.value.getFullYear();
|
const year = selectedDate.value.getFullYear();
|
||||||
const month = date.value.getMonth() + 1;
|
const month = selectedDate.value.getMonth() + 1;
|
||||||
const monthDoubleDigit = month < 10 ? `0${month}` : month.toString();
|
const monthDoubleDigit = month < 10 ? `0${month}` : month.toString();
|
||||||
const organisation = props.org;
|
const organisation = props.org;
|
||||||
|
|
||||||
const response = await pb.send(`/hours/${organisation}/${year}/${monthDoubleDigit}`, {});
|
const response = await pb.send(`/hours/${organisation}/${year}/${monthDoubleDigit}`, {});
|
||||||
|
|
||||||
|
console.log(response);
|
||||||
|
|
||||||
if (response.error === null) {
|
if (response.error === null) {
|
||||||
totalHours.value = Math.round(response.hours * 100) / 100;
|
let total = 0.0;
|
||||||
totalHoursVisible.value = true;
|
modalTotalHours.value.hours = {};
|
||||||
|
for (const project of Object.keys(response.hours)) {
|
||||||
|
modalTotalHours.value.hours[project] = Math.round(response.hours[project] * 100.0) / 100.0;
|
||||||
|
total = total + modalTotalHours.value.hours[project];
|
||||||
|
}
|
||||||
|
modalTotalHours.value.total = total;
|
||||||
|
modalTotalHours.value.visible = true;
|
||||||
} else {
|
} else {
|
||||||
console.error(response.error);
|
console.error(response.error);
|
||||||
}
|
}
|
||||||
|
|
@ -39,10 +54,21 @@ async function showTotalHours() {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Button label="<" @click="setDate(-1)" style="margin-left: 20px;" />
|
<Button label="<" @click="setDate(-1)" style="margin-left: 20px;" />
|
||||||
<DatePicker v-model="date" style="width: 150px;" />
|
<DatePicker v-model="selectedDate" style="width: 150px; margin-left: 3px;" />
|
||||||
<Button label=">" @click="setDate(1)" />
|
<Button label=">" @click="setDate(1)" style="margin-left: 3px;" />
|
||||||
<Button label="Total hours" @click="showTotalHours" style="margin-left: 20px;" />
|
<Button label="total hours" @click="showModalTotalHours" style="margin-left: 20px;" />
|
||||||
<Dialog v-model:visible="totalHoursVisible" modal header="Total hours" :style="{ width: '25rem' }">
|
<Dialog v-model:visible="modalTotalHours.visible" modal header="Total hours">
|
||||||
<p>Hours: {{ totalHours }}</p>
|
<table style="width: 100%; border-collapse: collapse; border: 1px solid #ccc; font-family: Arial, sans-serif; font-size: 14px;">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="project in Object.keys(modalTotalHours.hours)">
|
||||||
|
<td style="border: 1px solid #ccc; padding: 8px;">{{ project }}</td>
|
||||||
|
<td style="border: 1px solid #ccc; padding: 8px;">{{ modalTotalHours.hours[project] }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="border: 1px solid #ccc; padding: 8px; background: #f4f4f4; font-weight: bold;">Total hours</td>
|
||||||
|
<td style="border: 1px solid #ccc; padding: 8px; background: #f4f4f4; font-weight: bold;">{{ modalTotalHours.total }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -30,17 +30,17 @@ const props = defineProps<{
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const orgWatch = toRef(() => props.org);
|
|
||||||
const dateWatch = toRef(() => props.date);
|
|
||||||
const entries = ref<Entry[]>([]);
|
|
||||||
const editingRows = ref([]);
|
const editingRows = ref([]);
|
||||||
|
const entries = ref<Entry[]>([]);
|
||||||
const newEntrySelection = ref("");
|
const newEntrySelection = ref("");
|
||||||
|
const watchDate = toRef(() => props.date);
|
||||||
|
const watchOrganisation = toRef(() => props.org);
|
||||||
|
|
||||||
watch(dateWatch, () => {
|
watch(watchDate, () => {
|
||||||
getEntries().then();
|
getEntries().then();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(orgWatch, () => {
|
watch(watchOrganisation, () => {
|
||||||
getEntries().then();
|
getEntries().then();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -49,39 +49,36 @@ onMounted(async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const summaries = computed(() => {
|
const summaries = computed(() => {
|
||||||
const e = entries.value.filter((el) => {
|
const hours: { [key: string]: number } = {};
|
||||||
return el.end !== null && el.end.getTime() > el.start.getTime() && el.project !== "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const minutes: { [key: string]: number } = {};
|
for (const entry of entries.value) {
|
||||||
|
if (
|
||||||
for (const el of e) {
|
entry.end === null ||
|
||||||
if (el.end !== null) {
|
entry.end.getTime() <= entry.start.getTime() ||
|
||||||
const pid = el.project;
|
entry.project === ""
|
||||||
if (!(pid in minutes)) {
|
) {
|
||||||
minutes[pid] = 0;
|
continue;
|
||||||
}
|
|
||||||
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 }[] = [];
|
const project_id = entry.project;
|
||||||
let cumsum = 0;
|
|
||||||
for (const key in minutes) {
|
if (!(project_id in hours)) {
|
||||||
const start = new Date(props.date.getTime());
|
hours[project_id] = 0;
|
||||||
start.setHours(8, 0, 0);
|
|
||||||
start.setMinutes(start.getMinutes() + cumsum);
|
|
||||||
cumsum += minutes[key];
|
|
||||||
const end = new Date(props.date.getTime());
|
|
||||||
end.setHours(8, 0, 0);
|
|
||||||
end.setMinutes(end.getMinutes() + cumsum);
|
|
||||||
result.push({ key, start, end });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
const end = new Date(entry.end).getTime();
|
||||||
|
const start = new Date(entry.start).getTime();
|
||||||
|
const elapsed = Math.round(((end - start) / (1000 * 60 * 60)) * 100.0) / 100.0;
|
||||||
|
hours[project_id] = hours[project_id] + elapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: { project_id: string; hours: number }[] = [];
|
||||||
|
|
||||||
|
for (const [project_id, total] of Object.entries(hours)) {
|
||||||
|
out.push({ project_id, hours: total });
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isEditorActive = computed(() => {
|
const isEditorActive = computed(() => {
|
||||||
|
|
@ -149,13 +146,13 @@ function getDateFmt(date: Date | null): string {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const hour = date.getHours();
|
const year = date.getFullYear();
|
||||||
const minute = date.getMinutes();
|
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);
|
||||||
|
|
||||||
const hh = `0${hour.toString()}`.slice(-2);
|
return `${date_str}/${month}/${year} ${hour}:${minute}`;
|
||||||
const mm = `0${minute.toString()}`.slice(-2);
|
|
||||||
|
|
||||||
return `${hh}:${mm}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(event: DataTableRowEditSaveEvent) {
|
async function save(event: DataTableRowEditSaveEvent) {
|
||||||
|
|
@ -265,7 +262,7 @@ function projectTitleFormat(project: Project, titleLimit: number): string {
|
||||||
{{ getDateFmt(slotProps.data.start) }}
|
{{ getDateFmt(slotProps.data.start) }}
|
||||||
</template>
|
</template>
|
||||||
<template #editor="{ data, field }">
|
<template #editor="{ data, field }">
|
||||||
<DatePicker v-model="data[field]" timeOnly style="min-width: 100px;" />
|
<DatePicker v-model="data[field]" showTime style="min-width: 100px;" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="end" header="End">
|
<Column field="end" header="End">
|
||||||
|
|
@ -273,7 +270,7 @@ function projectTitleFormat(project: Project, titleLimit: number): string {
|
||||||
{{ getDateFmt(slotProps.data.end) }}
|
{{ getDateFmt(slotProps.data.end) }}
|
||||||
</template>
|
</template>
|
||||||
<template #editor="{ data, field }">
|
<template #editor="{ data, field }">
|
||||||
<DatePicker v-if="data[field]" v-model="data[field]" timeOnly style="min-width: 100px;" />
|
<DatePicker v-if="data[field]" v-model="data[field]" showTime style="min-width: 100px;" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="description" header="Description">
|
<Column field="description" header="Description">
|
||||||
|
|
@ -299,17 +296,12 @@ function projectTitleFormat(project: Project, titleLimit: number): string {
|
||||||
<DataTable v-if="summaries.length > 0" :value="summaries">
|
<DataTable v-if="summaries.length > 0" :value="summaries">
|
||||||
<Column header="Project">
|
<Column header="Project">
|
||||||
<template #body="slotProps">
|
<template #body="slotProps">
|
||||||
{{ projectTitleFormat(getProjectById(slotProps.data.key), 60) }}
|
{{ projectTitleFormat(getProjectById(slotProps.data.project_id), 60) }}
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column header="Start">
|
<Column header="Hours">
|
||||||
<template #body="slotProps">
|
<template #body="slotProps">
|
||||||
{{ getDateFmt(slotProps.data.start) }}
|
{{ slotProps.data.hours }}
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column header="End">
|
|
||||||
<template #body="slotProps">
|
|
||||||
{{ getDateFmt(slotProps.data.end) }}
|
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue