refactor time entry and projecttaskdropdown components to not rely on pinia stores

This commit is contained in:
Gregor Vostrak
2024-07-09 18:40:12 +02:00
parent 375cee7589
commit a519c119d4
12 changed files with 201 additions and 256 deletions

View File

@@ -61,12 +61,6 @@ async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) {
); );
} }
async function assertThatTimeEntryRowIsStarted(newTimeEntry: Locator) {
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(
/bg-red-400\/80/
);
}
test('test that updating a description of a time entry in the overview works on blur', async ({ test('test that updating a description of a time entry in the overview works on blur', async ({
page, page,
}) => { }) => {
@@ -259,48 +253,6 @@ test('test that updating a the duration in the overview works on blur', async ({
).toHaveValue('0h 20min'); ).toHaveValue('0h 20min');
}); });
// Test that start stop button stops running timer
test('test that stopping a time entry from the overview works', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
assertThatTimerHasStarted(page),
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
),
]);
await page.waitForTimeout(1500);
const newTimeEntry = timeEntryRows.first();
const stopButton = newTimeEntry.getByTestId('timer_button');
await assertThatTimeEntryRowIsStarted(newTimeEntry);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null
);
}),
stopButton.click(),
]);
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(
/bg-accent-300\/70/
);
});
// Test that start stop button stops running timer // Test that start stop button stops running timer
test('test that starting a time entry from the overview works', async ({ test('test that starting a time entry from the overview works', async ({
page, page,
@@ -327,7 +279,8 @@ test('test that starting a time entry from the overview works', async ({
startButton.click(), startButton.click(),
]); ]);
await expect(startButton).toHaveClass(/bg-red-500\/80/); await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500); await page.waitForTimeout(1500);
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
@@ -341,67 +294,7 @@ test('test that starting a time entry from the overview works', async ({
); );
}), }),
startOrStopTimerWithButton(page), startOrStopTimerWithButton(page),
expect(startButton).toHaveClass(/bg-accent-300\/70/), assertThatTimerIsStopped(page),
]);
});
test('test that updating a the duration in the overview for a running timer works on blur', async ({
page,
}) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
assertThatTimerHasStarted(page),
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
),
]);
await page.waitForTimeout(1500);
const newTimeEntry = timeEntryRows.first();
const startButton = newTimeEntry.getByTestId('timer_button');
await page.waitForTimeout(1500);
const timeEntryDurationInput = newTimeEntry.getByTestId(
'time_entry_duration_input'
);
await timeEntryDurationInput.fill('20min');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
// TODO! Actually check the value
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null
);
}),
timeEntryDurationInput.press('Tab'),
]);
await expect(page.getByTestId('time_entry_time')).toHaveValue('00:20:00');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null
);
}),
startOrStopTimerWithButton(page),
expect(startButton).toHaveClass(/bg-accent-300\/70/),
]); ]);
}); });
@@ -468,3 +361,5 @@ test.skip('test that load more works when the end of page is reached', async ({
// TODO: Test Grouped time entries by description/project // TODO: Test Grouped time entries by description/project
// TODO: Add Test for Date Update // TODO: Add Test for Date Update
// TODO: Test that project can be created in the time entry row

View File

@@ -279,3 +279,5 @@ test('test that adding a new tag when the timer is running', async ({
// test that sidebar timetracker changes state when tmer on dashboard is started // test that sidebar timetracker changes state when tmer on dashboard is started
// test billable toggle // test billable toggle
// TODO: Test that project can be created in the time tracker row

View File

@@ -6,22 +6,26 @@ import { computed, ref } from 'vue';
import type { CreateProjectBody } from '@/utils/api'; import type { CreateProjectBody } from '@/utils/api';
import { getRandomColor } from '@/utils/color'; import { getRandomColor } from '@/utils/color';
import PrimaryButton from '@/Components/PrimaryButton.vue'; import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useProjectsStore } from '@/utils/useProjects';
import { useFocus } from '@vueuse/core'; import { useFocus } from '@vueuse/core';
import ClientDropdown from '@/Components/Common/Client/ClientDropdown.vue'; import ClientDropdown from '@/Components/Common/Client/ClientDropdown.vue';
import Badge from '@/Components/Common/Badge.vue'; import Badge from '@/Components/Common/Badge.vue';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue'; import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid'; import { UserCircleIcon } from '@heroicons/vue/20/solid';
import InputLabel from '@/Components/InputLabel.vue'; import InputLabel from '@/Components/InputLabel.vue';
import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue'; import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue';
import type { Client } from '@/utils/api';
const { createProject } = useProjectsStore();
const { clients } = storeToRefs(useClientsStore());
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = ref(false); const saving = ref(false);
const props = defineProps<{
clients: Client[];
}>();
const emit = defineEmits<{
submit: [project: CreateProjectBody, callback: () => void];
}>();
const project = ref<CreateProjectBody>({ const project = ref<CreateProjectBody>({
name: '', name: '',
color: getRandomColor(), color: getRandomColor(),
@@ -31,15 +35,16 @@ const project = ref<CreateProjectBody>({
}); });
async function submit() { async function submit() {
await createProject(project.value); emit('submit', project.value, () => {
show.value = false; show.value = false;
project.value = { project.value = {
name: '', name: '',
color: getRandomColor(), color: getRandomColor(),
client_id: null, client_id: null,
billable_rate: null, billable_rate: null,
is_billable: false, is_billable: false,
}; };
});
} }
const projectNameInput = ref<HTMLInputElement | null>(null); const projectNameInput = ref<HTMLInputElement | null>(null);
@@ -48,7 +53,7 @@ useFocus(projectNameInput, { initialValue: true });
const currentClientName = computed(() => { const currentClientName = computed(() => {
if (project.value.client_id) { if (project.value.client_id) {
return clients.value.find( return props.clients.find(
(client) => client.id === project.value.client_id (client) => client.id === project.value.client_id
)?.name; )?.name;
} }

View File

@@ -7,17 +7,28 @@ import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.v
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue'; import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue'; import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import type { Project } from '@/utils/api'; import type { CreateProjectBody, Project } from '@/utils/api';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
defineProps<{ defineProps<{
projects: Project[]; projects: Project[];
}>(); }>();
const createProject = ref(false); const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody, callback: () => void) {
await useProjectsStore().createProject(project);
callback();
}
const { clients } = storeToRefs(useClientsStore());
</script> </script>
<template> <template>
<ProjectCreateModal v-model:show="createProject"></ProjectCreateModal> <ProjectCreateModal
:clients="clients"
@submit="createProject"
v-model:show="showCreateProjectModal"></ProjectCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto"> <div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle"> <div class="inline-block min-w-full align-middle">
<div <div
@@ -36,7 +47,7 @@ const createProject = ref(false);
</p> </p>
<SecondaryButton <SecondaryButton
v-if="canCreateProjects()" v-if="canCreateProjects()"
@click="createProject = true" @click="showCreateProjectModal = true"
:icon="PlusIcon" :icon="PlusIcon"
>Create your First Project >Create your First Project
</SecondaryButton> </SecondaryButton>

View File

@@ -1,16 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import MainContainer from '@/Pages/MainContainer.vue'; import MainContainer from '@/Pages/MainContainer.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue'; import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import type { TimeEntry } from '@/utils/api'; import type { Project, Task, TimeEntry } from '@/utils/api';
import { storeToRefs } from 'pinia';
import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue'; import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
import { import { type TimeEntriesGroupedByType } from '@/utils/useTimeEntries';
type TimeEntriesGroupedByType,
useTimeEntriesStore,
} from '@/utils/useTimeEntries';
import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue'; import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue';
import dayjs from 'dayjs';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue'; import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue'; import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue'; import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
@@ -22,80 +16,49 @@ import {
import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue'; import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue';
import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue'; import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue';
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { stopTimer } = currentTimeEntryStore;
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
const props = defineProps<{ const props = defineProps<{
timeEntry: TimeEntriesGroupedByType; timeEntry: TimeEntriesGroupedByType;
projects: Project[];
tasks: Task[];
}>(); }>();
const { updateTimeEntry, createTimeEntry, fetchTimeEntries } = const emit = defineEmits<{
useTimeEntriesStore(); onStartStopClick: [timeEntry: TimeEntry];
updateTimeEntries: [timeEntries: TimeEntry[]];
async function onStartStopClick() { deleteTimeEntries: [timeEntries: TimeEntry[]];
if (props.timeEntry.start && !props.timeEntry.end) { }>();
await updateTimeEntry({
...props.timeEntry,
end: dayjs().utc().format(),
});
} else {
if (currentTimeEntry.value.id) {
await stopTimer();
}
await createTimeEntry({
...props.timeEntry,
start: dayjs().utc().format(),
end: null,
});
}
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
fetchTimeEntries();
}
function deleteTimeEntry() {
const timeEntries = props.timeEntry.timeEntries;
timeEntries.forEach((entry) => {
useTimeEntriesStore().deleteTimeEntry(entry.id);
});
fetchTimeEntries();
}
function updateTimeEntryDescription(description: string) { function updateTimeEntryDescription(description: string) {
const timeEntries = props.timeEntry.timeEntries; const timeEntries = props.timeEntry.timeEntries;
timeEntries.forEach((entry) => { timeEntries.forEach((entry) => {
updateTimeEntry({ ...entry, description });
entry.description = description; entry.description = description;
}); });
emit('updateTimeEntries', timeEntries);
} }
function updateTimeEntryTags(tags: string[]) { function updateTimeEntryTags(tags: string[]) {
const timeEntries = props.timeEntry.timeEntries as TimeEntry[]; const timeEntries = props.timeEntry.timeEntries as TimeEntry[];
timeEntries.forEach((entry) => { timeEntries.forEach((entry) => {
updateTimeEntry({ ...entry, tags });
entry.tags = tags; entry.tags = tags;
}); });
emit('updateTimeEntries', timeEntries);
} }
function updateTimeEntryBillable(billable: boolean) { function updateTimeEntryBillable(billable: boolean) {
const timeEntries = props.timeEntry.timeEntries as TimeEntry[]; const timeEntries = props.timeEntry.timeEntries as TimeEntry[];
timeEntries.forEach((entry) => { timeEntries.forEach((entry) => {
updateTimeEntry({ ...entry, billable });
entry.billable = billable; entry.billable = billable;
}); });
emit('updateTimeEntries', timeEntries);
} }
function updateProjectAndTask(projectId: string, taskId: string) { function updateProjectAndTask(projectId: string, taskId: string) {
const timeEntries = props.timeEntry.timeEntries as TimeEntry[]; const timeEntries = props.timeEntry.timeEntries as TimeEntry[];
timeEntries.forEach((entry) => { timeEntries.forEach((entry) => {
updateTimeEntry({
...entry,
project_id: projectId,
task_id: taskId,
});
entry.project_id = projectId; entry.project_id = projectId;
entry.task_id = taskId; entry.task_id = taskId;
}); });
emit('updateTimeEntries', timeEntries);
} }
const expanded = ref(false); const expanded = ref(false);
@@ -124,6 +87,8 @@ const expanded = ref(false);
"></TimeEntryDescriptionInput> "></TimeEntryDescriptionInput>
</div> </div>
<TimeTrackerProjectTaskDropdown <TimeTrackerProjectTaskDropdown
:projects="projects"
:tasks="tasks"
:showBadgeBorder="false" :showBadgeBorder="false"
@changed="updateProjectAndTask" @changed="updateProjectAndTask"
:project="timeEntry.project_id" :project="timeEntry.project_id"
@@ -157,12 +122,12 @@ const expanded = ref(false);
</button> </button>
<TimeTrackerStartStop <TimeTrackerStartStop
@changed="onStartStopClick" @changed="emit('onStartStopClick', timeEntry)"
:active="!!(timeEntry.start && !timeEntry.end)" :active="!!(timeEntry.start && !timeEntry.end)"
class="opacity-20 hidden sm:flex group-hover:opacity-100"></TimeTrackerStartStop> class="opacity-20 hidden sm:flex group-hover:opacity-100"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown <TimeEntryMoreOptionsDropdown
@delete=" @delete="
deleteTimeEntry emit('deleteTimeEntries', timeEntry.timeEntries)
"></TimeEntryMoreOptionsDropdown> "></TimeEntryMoreOptionsDropdown>
</div> </div>
</div> </div>
@@ -171,7 +136,14 @@ const expanded = ref(false);
v-if="expanded" v-if="expanded"
class="w-full border-t border-default-background-separator bg-black/15"> class="w-full border-t border-default-background-separator bg-black/15">
<TimeEntryRow <TimeEntryRow
:projects="projects"
:tasks="tasks"
indent indent
@updateTimeEntry="
(timeEntry) => emit('updateTimeEntries', [timeEntry])
"
@onStartStopClick="emit('onStartStopClick', subEntry)"
@deleteTimeEntry="emit('deleteTimeEntries', [subEntry])"
:key="subEntry.id" :key="subEntry.id"
v-for="subEntry in timeEntry.timeEntries" v-for="subEntry in timeEntry.timeEntries"
:time-entry="subEntry"></TimeEntryRow> :time-entry="subEntry"></TimeEntryRow>

View File

@@ -13,6 +13,13 @@ import InputLabel from '@/Components/InputLabel.vue';
import TimePicker from '@/Components/Common/TimePicker.vue'; import TimePicker from '@/Components/Common/TimePicker.vue';
import DatePicker from '@/Components/Common/DatePicker.vue'; import DatePicker from '@/Components/Common/DatePicker.vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/utils/time'; import { getDayJsInstance, getLocalizedDayJs } from '@/utils/time';
import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects';
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
const { createTimeEntry } = useTimeEntriesStore(); const { createTimeEntry } = useTimeEntriesStore();
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
@@ -92,6 +99,8 @@ async function submit() {
<TimeTrackerProjectTaskDropdown <TimeTrackerProjectTaskDropdown
class="mt-1" class="mt-1"
size="xlarge" size="xlarge"
:projects="projects"
:tasks="tasks"
v-model:project="timeEntry.project_id" v-model:project="timeEntry.project_id"
v-model:task=" v-model:task="
timeEntry.task_id timeEntry.task_id

View File

@@ -9,7 +9,9 @@ defineProps<{
end: string | null; end: string | null;
}>(); }>();
const emit = defineEmits(['changed']); const emit = defineEmits<{
changed: [start: string, end: string | null];
}>();
const open = ref(false); const open = ref(false);
</script> </script>

View File

@@ -2,80 +2,45 @@
import MainContainer from '@/Pages/MainContainer.vue'; import MainContainer from '@/Pages/MainContainer.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue'; import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import TimeEntryRangeSelector from '@/Components/Common/TimeEntry/TimeEntryRangeSelector.vue'; import TimeEntryRangeSelector from '@/Components/Common/TimeEntry/TimeEntryRangeSelector.vue';
import type { TimeEntry } from '@/utils/api'; import type { Project, Task, TimeEntry } from '@/utils/api';
import { storeToRefs } from 'pinia';
import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue'; import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue'; import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue';
import TimeEntryRowDurationInput from '@/Components/Common/TimeEntry/TimeEntryRowDurationInput.vue'; import TimeEntryRowDurationInput from '@/Components/Common/TimeEntry/TimeEntryRowDurationInput.vue';
import dayjs from 'dayjs';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue'; import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue'; import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue'; import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { stopTimer, updateTimer } = currentTimeEntryStore;
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
const props = defineProps<{ const props = defineProps<{
timeEntry: TimeEntry; timeEntry: TimeEntry;
indent?: boolean; indent?: boolean;
projects: Project[];
tasks: Task[];
}>(); }>();
const { updateTimeEntry, createTimeEntry, fetchTimeEntries } = const emit = defineEmits<{
useTimeEntriesStore(); onStartStopClick: [];
deleteTimeEntry: [];
async function updateStartEndTime(start: string, end: string | null) { updateTimeEntry: [timeEntry: TimeEntry];
if (currentTimeEntry.value.id === props.timeEntry.id) { }>();
currentTimeEntry.value.start = start;
currentTimeEntry.value.end = end;
await updateTimer();
} else {
await updateTimeEntry({ ...props.timeEntry, start, end });
}
await fetchTimeEntries();
}
async function onStartStopClick() {
if (props.timeEntry.start && !props.timeEntry.end) {
await updateTimeEntry({
...props.timeEntry,
end: dayjs().utc().format(),
});
} else {
if (currentTimeEntry.value.id) {
await stopTimer();
}
await createTimeEntry({
...props.timeEntry,
start: dayjs().utc().format(),
end: null,
});
}
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
fetchTimeEntries();
}
function deleteTimeEntry() {
useTimeEntriesStore().deleteTimeEntry(props.timeEntry.id);
fetchTimeEntries();
}
function updateTimeEntryDescription(description: string) { function updateTimeEntryDescription(description: string) {
updateTimeEntry({ ...props.timeEntry, description }); emit('updateTimeEntry', { ...props.timeEntry, description });
} }
function updateTimeEntryTags(tags: string[]) { function updateTimeEntryTags(tags: string[]) {
updateTimeEntry({ ...props.timeEntry, tags }); emit('updateTimeEntry', { ...props.timeEntry, tags });
} }
function updateTimeEntryBillable(billable: boolean) { function updateTimeEntryBillable(billable: boolean) {
updateTimeEntry({ ...props.timeEntry, billable }); emit('updateTimeEntry', { ...props.timeEntry, billable });
}
function updateStartEndTime(start: string, end: string | null) {
emit('updateTimeEntry', { ...props.timeEntry, start, end });
} }
function updateProjectAndTask(projectId: string, taskId: string) { function updateProjectAndTask(projectId: string, taskId: string) {
updateTimeEntry({ emit('updateTimeEntry', {
...props.timeEntry, ...props.timeEntry,
project_id: projectId, project_id: projectId,
task_id: taskId, task_id: taskId,
@@ -100,6 +65,8 @@ function updateProjectAndTask(projectId: string, taskId: string) {
timeEntry.description timeEntry.description
"></TimeEntryDescriptionInput> "></TimeEntryDescriptionInput>
<TimeTrackerProjectTaskDropdown <TimeTrackerProjectTaskDropdown
:projects="projects"
:tasks="tasks"
:showBadgeBorder="false" :showBadgeBorder="false"
@changed="updateProjectAndTask" @changed="updateProjectAndTask"
:project="timeEntry.project_id" :project="timeEntry.project_id"
@@ -132,12 +99,12 @@ function updateProjectAndTask(projectId: string, taskId: string) {
updateStartEndTime updateStartEndTime
"></TimeEntryRowDurationInput> "></TimeEntryRowDurationInput>
<TimeTrackerStartStop <TimeTrackerStartStop
@changed="onStartStopClick" @changed="emit('onStartStopClick')"
:active="!!(timeEntry.start && !timeEntry.end)" :active="!!(timeEntry.start && !timeEntry.end)"
class="opacity-20 hidden sm:flex group-hover:opacity-100"></TimeTrackerStartStop> class="opacity-20 hidden sm:flex group-hover:opacity-100"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown <TimeEntryMoreOptionsDropdown
@delete=" @delete="
deleteTimeEntry emit('deleteTimeEntry')
"></TimeEntryMoreOptionsDropdown> "></TimeEntryMoreOptionsDropdown>
</div> </div>
</div> </div>

View File

@@ -2,20 +2,15 @@
import { ChevronRightIcon } from '@heroicons/vue/16/solid'; import { ChevronRightIcon } from '@heroicons/vue/16/solid';
import Dropdown from '@/Components/Dropdown.vue'; import Dropdown from '@/Components/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue'; import { type Component, computed, nextTick, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useProjectsStore } from '@/utils/useProjects';
import { useTasksStore } from '@/utils/useTasks';
import ProjectDropdownItem from '@/Components/Common/Project/ProjectDropdownItem.vue'; import ProjectDropdownItem from '@/Components/Common/Project/ProjectDropdownItem.vue';
import type { Project, Task } from '@/utils/api'; import type { CreateProjectBody, Project, Task } from '@/utils/api';
import ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue'; import ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue';
import Badge from '@/Components/Common/Badge.vue'; import Badge from '@/Components/Common/Badge.vue';
import { PlusIcon, PlusCircleIcon } from '@heroicons/vue/16/solid'; import { PlusIcon, PlusCircleIcon } from '@heroicons/vue/16/solid';
import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.vue'; import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.vue';
import { useClientsStore } from '@/utils/useClients';
const projectStore = useProjectsStore(); import { storeToRefs } from 'pinia';
const { projects } = storeToRefs(projectStore); import { useProjectsStore } from '@/utils/useProjects';
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
const task = defineModel<string | null>('task', { const task = defineModel<string | null>('task', {
default: null, default: null,
@@ -45,10 +40,12 @@ type ProjectWithTasks = {
tasks: Task[]; tasks: Task[];
}; };
withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
showBadgeBorder: boolean; showBadgeBorder: boolean;
size: 'base' | 'large' | 'xlarge'; size: 'base' | 'large' | 'xlarge';
projects: Project[];
tasks: Task[];
}>(), }>(),
{ {
showBadgeBorder: true, showBadgeBorder: true,
@@ -57,14 +54,14 @@ withDefaults(
); );
const filteredProjects = computed(() => { const filteredProjects = computed(() => {
return projects.value.reduce( return props.projects.reduce(
(filtered: ProjectWithTasks[], filterProject) => { (filtered: ProjectWithTasks[], filterProject) => {
const projectNameIncludesSearchTerm = filterProject.name const projectNameIncludesSearchTerm = filterProject.name
.toLowerCase() .toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || ''); .includes(searchValue.value?.toLowerCase()?.trim() || '');
// check if one of the project tasks // check if one of the project tasks
const projectTasks = tasks.value.filter((task) => { const projectTasks = props.tasks.filter((task) => {
return task.project_id === filterProject.id; return task.project_id === filterProject.id;
}); });
@@ -277,13 +274,13 @@ function moveHighlightDown() {
const highlightedItemId = ref<string | null>(null); const highlightedItemId = ref<string | null>(null);
const currentProject = computed(() => { const currentProject = computed(() => {
return projects.value.find( return props.projects.find(
(iteratingProject) => iteratingProject.id === project.value (iteratingProject) => iteratingProject.id === project.value
); );
}); });
const currentTask = computed(() => { const currentTask = computed(() => {
return tasks.value.find( return props.tasks.find(
(iteratingTasks) => iteratingTasks.id === task.value (iteratingTasks) => iteratingTasks.id === task.value
); );
}); });
@@ -299,7 +296,7 @@ const selectedProjectColor = computed(() => {
function selectTask(taskId: string) { function selectTask(taskId: string) {
task.value = taskId; task.value = taskId;
project.value = project.value =
tasks.value.find((task) => task.id === taskId)?.project_id || null; props.tasks.find((task) => task.id === taskId)?.project_id || null;
open.value = false; open.value = false;
emit('changed', project.value, task.value); emit('changed', project.value, task.value);
} }
@@ -312,6 +309,14 @@ function selectProject(projectId: string) {
} }
const showCreateProject = ref(false); const showCreateProject = ref(false);
const clientsStore = useClientsStore();
const { clients } = storeToRefs(clientsStore);
async function createProject(project: CreateProjectBody, callback: () => void) {
await useProjectsStore().createProject(project);
callback();
}
</script> </script>
<template> <template>
@@ -412,7 +417,10 @@ const showCreateProject = ref(false);
</div> </div>
</template> </template>
</Dropdown> </Dropdown>
<ProjectCreateModal v-model:show="showCreateProject"></ProjectCreateModal> <ProjectCreateModal
:clients="clients"
@submit="createProject"
v-model:show="showCreateProject"></ProjectCreateModal>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -19,6 +19,7 @@ import { switchOrganization } from '@/utils/useOrganization';
import SecondaryButton from '@/Components/SecondaryButton.vue'; import SecondaryButton from '@/Components/SecondaryButton.vue';
import TimeTrackerRangeSelector from '@/Components/Common/TimeTracker/TimeTrackerRangeSelector.vue'; import TimeTrackerRangeSelector from '@/Components/Common/TimeTracker/TimeTrackerRangeSelector.vue';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useTasksStore } from '@/utils/useTasks';
const page = usePage<{ const page = usePage<{
auth: { auth: {
@@ -34,6 +35,11 @@ const { currentTimeEntry, isActive, now } = storeToRefs(currentTimeEntryStore);
const { startLiveTimer, stopLiveTimer, setActiveState } = currentTimeEntryStore; const { startLiveTimer, stopLiveTimer, setActiveState } = currentTimeEntryStore;
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null); const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
watch(isActive, () => { watch(isActive, () => {
if (isActive.value) { if (isActive.value) {
startLiveTimer(); startLiveTimer();
@@ -133,6 +139,8 @@ function switchToTimeEntryOrganization() {
<div class="flex items-center justify-between pl-2"> <div class="flex items-center justify-between pl-2">
<div class="flex items-center w-[130px] sm:w-auto"> <div class="flex items-center w-[130px] sm:w-auto">
<TimeTrackerProjectTaskDropdown <TimeTrackerProjectTaskDropdown
:projects="projects"
:tasks="tasks"
@changed="updateProject" @changed="updateProject"
v-model:project="currentTimeEntry.project_id" v-model:project="currentTimeEntry.project_id"
v-model:task=" v-model:task="

View File

@@ -12,12 +12,14 @@ import { canCreateProjects } from '@/utils/permissions';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue'; import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue'; import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useClientsStore } from '@/utils/useClients';
import type { CreateProjectBody } from '@/utils/api';
onMounted(() => { onMounted(() => {
useProjectsStore().fetchProjects(); useProjectsStore().fetchProjects();
}); });
const { clients } = storeToRefs(useClientsStore());
const createProject = ref(false); const showCreateProjectModal = ref(false);
const activeTab = ref<'active' | 'archived'>('active'); const activeTab = ref<'active' | 'archived'>('active');
@@ -25,6 +27,11 @@ function isActiveTab(tab: string) {
return activeTab.value === tab; return activeTab.value === tab;
} }
async function createProject(project: CreateProjectBody, callback: () => void) {
await useProjectsStore().createProject(project);
callback();
}
const { projects } = storeToRefs(useProjectsStore()); const { projects } = storeToRefs(useProjectsStore());
const shownProjects = computed(() => { const shownProjects = computed(() => {
@@ -59,11 +66,13 @@ const shownProjects = computed(() => {
<SecondaryButton <SecondaryButton
v-if="canCreateProjects()" v-if="canCreateProjects()"
:icon="PlusIcon" :icon="PlusIcon"
@click="createProject = true" @click="showCreateProjectModal = true"
>Create Project >Create Project
</SecondaryButton> </SecondaryButton>
<ProjectCreateModal <ProjectCreateModal
v-model:show="createProject"></ProjectCreateModal> :clients="clients"
@submit="createProject"
v-model:show="showCreateProjectModal"></ProjectCreateModal>
</MainContainer> </MainContainer>
<ProjectTable :projects="shownProjects"></ProjectTable> <ProjectTable :projects="shownProjects"></ProjectTable>
</AppLayout> </AppLayout>

View File

@@ -19,14 +19,51 @@ import { PlusIcon } from '@heroicons/vue/16/solid';
import TimeEntryCreateModal from '@/Components/Common/TimeEntry/TimeEntryCreateModal.vue'; import TimeEntryCreateModal from '@/Components/Common/TimeEntry/TimeEntryCreateModal.vue';
import TimeEntryAggregateRow from '@/Components/Common/TimeEntry/TimeEntryAggregateRow.vue'; import TimeEntryAggregateRow from '@/Components/Common/TimeEntry/TimeEntryAggregateRow.vue';
import LoadingSpinner from '@/Components/LoadingSpinner.vue'; import LoadingSpinner from '@/Components/LoadingSpinner.vue';
import dayjs from 'dayjs';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects';
const timeEntriesStore = useTimeEntriesStore(); const timeEntriesStore = useTimeEntriesStore();
const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore); const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore);
const { updateTimeEntry, fetchTimeEntries, createTimeEntry } =
useTimeEntriesStore();
function updateTimeEntries(timeEntries: TimeEntry[]) {
timeEntries.forEach((entry) => {
useTimeEntriesStore().updateTimeEntry(entry);
});
fetchTimeEntries();
}
const loading = ref(false); const loading = ref(false);
const loadMoreContainer = ref<HTMLDivElement | null>(null); const loadMoreContainer = ref<HTMLDivElement | null>(null);
const isLoadMoreVisible = useElementVisibility(loadMoreContainer); const isLoadMoreVisible = useElementVisibility(loadMoreContainer);
async function onStartStopClick(timeEntry: TimeEntry) {
if (timeEntry.start && !timeEntry.end) {
await updateTimeEntry({
...timeEntry,
end: dayjs().utc().format(),
});
} else {
await createTimeEntry({
...timeEntry,
start: dayjs().utc().format(),
end: null,
});
}
fetchTimeEntries();
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
}
function deleteTimeEntries(timeEntries: TimeEntry[]) {
timeEntries.forEach((entry) => {
useTimeEntriesStore().deleteTimeEntry(entry.id);
});
fetchTimeEntries();
}
watch(isLoadMoreVisible, async (isVisible) => { watch(isLoadMoreVisible, async (isVisible) => {
if ( if (
isVisible && isVisible &&
@@ -45,6 +82,10 @@ onMounted(async () => {
const groupedTimeEntries = computed(() => { const groupedTimeEntries = computed(() => {
const groupedEntriesByDay: Record<string, TimeEntry[]> = {}; const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
for (const entry of timeEntries.value) { for (const entry of timeEntries.value) {
// skip current time entry
if (entry.end === null) {
continue;
}
const oldEntries = const oldEntries =
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)]; groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)];
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)] = [ groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)] = [
@@ -104,6 +145,10 @@ const groupedTimeEntries = computed(() => {
return groupedEntriesByDayAndType; return groupedEntriesByDayAndType;
}); });
const showManualTimeEntryModal = ref(false); const showManualTimeEntryModal = ref(false);
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
</script> </script>
<template> <template>
@@ -131,11 +176,23 @@ const showManualTimeEntryModal = ref(false);
<TimeEntryRowHeading :date="key"></TimeEntryRowHeading> <TimeEntryRowHeading :date="key"></TimeEntryRowHeading>
<template v-for="entry in value" :key="entry.id"> <template v-for="entry in value" :key="entry.id">
<TimeEntryAggregateRow <TimeEntryAggregateRow
:projects="projects"
:tasks="tasks"
@onStartStopClick="onStartStopClick"
@updateTimeEntries="updateTimeEntries"
@deleteTimeEntries="deleteTimeEntries"
v-if=" v-if="
'timeEntries' in entry && entry.timeEntries.length > 1 'timeEntries' in entry && entry.timeEntries.length > 1
" "
:time-entry="entry"></TimeEntryAggregateRow> :time-entry="entry"></TimeEntryAggregateRow>
<TimeEntryRow v-else :time-entry="entry"></TimeEntryRow> <TimeEntryRow
:projects="projects"
:tasks="tasks"
@updateTimeEntry="updateTimeEntry"
@onStartStopClick="onStartStopClick(entry)"
@deleteTimeEntry="deleteTimeEntries([entry])"
v-else
:time-entry="entry"></TimeEntryRow>
</template> </template>
</div> </div>
<div <div