mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
refactor time entry and projecttaskdropdown components to not rely on pinia stores
This commit is contained in:
115
e2e/time.spec.ts
115
e2e/time.spec.ts
@@ -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 ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -259,48 +253,6 @@ test('test that updating a the duration in the overview works on blur', async ({
|
||||
).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('test that starting a time entry from the overview works', async ({
|
||||
page,
|
||||
@@ -327,7 +279,8 @@ test('test that starting a time entry from the overview works', async ({
|
||||
startButton.click(),
|
||||
]);
|
||||
|
||||
await expect(startButton).toHaveClass(/bg-red-500\/80/);
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
@@ -341,67 +294,7 @@ test('test that starting a time entry from the overview works', async ({
|
||||
);
|
||||
}),
|
||||
startOrStopTimerWithButton(page),
|
||||
expect(startButton).toHaveClass(/bg-accent-300\/70/),
|
||||
]);
|
||||
});
|
||||
|
||||
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/),
|
||||
assertThatTimerIsStopped(page),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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: Add Test for Date Update
|
||||
|
||||
// TODO: Test that project can be created in the time entry row
|
||||
|
||||
@@ -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 billable toggle
|
||||
|
||||
// TODO: Test that project can be created in the time tracker row
|
||||
|
||||
@@ -6,22 +6,26 @@ import { computed, ref } from 'vue';
|
||||
import type { CreateProjectBody } from '@/utils/api';
|
||||
import { getRandomColor } from '@/utils/color';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import ClientDropdown from '@/Components/Common/Client/ClientDropdown.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 { UserCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import InputLabel from '@/Components/InputLabel.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 saving = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
clients: Client[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [project: CreateProjectBody, callback: () => void];
|
||||
}>();
|
||||
|
||||
const project = ref<CreateProjectBody>({
|
||||
name: '',
|
||||
color: getRandomColor(),
|
||||
@@ -31,15 +35,16 @@ const project = ref<CreateProjectBody>({
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
await createProject(project.value);
|
||||
show.value = false;
|
||||
project.value = {
|
||||
name: '',
|
||||
color: getRandomColor(),
|
||||
client_id: null,
|
||||
billable_rate: null,
|
||||
is_billable: false,
|
||||
};
|
||||
emit('submit', project.value, () => {
|
||||
show.value = false;
|
||||
project.value = {
|
||||
name: '',
|
||||
color: getRandomColor(),
|
||||
client_id: null,
|
||||
billable_rate: null,
|
||||
is_billable: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const projectNameInput = ref<HTMLInputElement | null>(null);
|
||||
@@ -48,7 +53,7 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
|
||||
const currentClientName = computed(() => {
|
||||
if (project.value.client_id) {
|
||||
return clients.value.find(
|
||||
return props.clients.find(
|
||||
(client) => client.id === project.value.client_id
|
||||
)?.name;
|
||||
}
|
||||
|
||||
@@ -7,17 +7,28 @@ import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.v
|
||||
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
||||
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<{
|
||||
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>
|
||||
|
||||
<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="inline-block min-w-full align-middle">
|
||||
<div
|
||||
@@ -36,7 +47,7 @@ const createProject = ref(false);
|
||||
</p>
|
||||
<SecondaryButton
|
||||
v-if="canCreateProjects()"
|
||||
@click="createProject = true"
|
||||
@click="showCreateProjectModal = true"
|
||||
:icon="PlusIcon"
|
||||
>Create your First Project
|
||||
</SecondaryButton>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
|
||||
import type { TimeEntry } from '@/utils/api';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { Project, Task, TimeEntry } from '@/utils/api';
|
||||
import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
|
||||
import {
|
||||
type TimeEntriesGroupedByType,
|
||||
useTimeEntriesStore,
|
||||
} from '@/utils/useTimeEntries';
|
||||
import { type TimeEntriesGroupedByType } from '@/utils/useTimeEntries';
|
||||
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 TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
|
||||
@@ -22,80 +16,49 @@ import {
|
||||
import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue';
|
||||
import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue';
|
||||
|
||||
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
||||
const { stopTimer } = currentTimeEntryStore;
|
||||
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntriesGroupedByType;
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
}>();
|
||||
|
||||
const { updateTimeEntry, createTimeEntry, fetchTimeEntries } =
|
||||
useTimeEntriesStore();
|
||||
|
||||
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() {
|
||||
const timeEntries = props.timeEntry.timeEntries;
|
||||
timeEntries.forEach((entry) => {
|
||||
useTimeEntriesStore().deleteTimeEntry(entry.id);
|
||||
});
|
||||
fetchTimeEntries();
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
onStartStopClick: [timeEntry: TimeEntry];
|
||||
updateTimeEntries: [timeEntries: TimeEntry[]];
|
||||
deleteTimeEntries: [timeEntries: TimeEntry[]];
|
||||
}>();
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
const timeEntries = props.timeEntry.timeEntries;
|
||||
timeEntries.forEach((entry) => {
|
||||
updateTimeEntry({ ...entry, description });
|
||||
entry.description = description;
|
||||
});
|
||||
emit('updateTimeEntries', timeEntries);
|
||||
}
|
||||
|
||||
function updateTimeEntryTags(tags: string[]) {
|
||||
const timeEntries = props.timeEntry.timeEntries as TimeEntry[];
|
||||
timeEntries.forEach((entry) => {
|
||||
updateTimeEntry({ ...entry, tags });
|
||||
entry.tags = tags;
|
||||
});
|
||||
emit('updateTimeEntries', timeEntries);
|
||||
}
|
||||
|
||||
function updateTimeEntryBillable(billable: boolean) {
|
||||
const timeEntries = props.timeEntry.timeEntries as TimeEntry[];
|
||||
timeEntries.forEach((entry) => {
|
||||
updateTimeEntry({ ...entry, billable });
|
||||
entry.billable = billable;
|
||||
});
|
||||
emit('updateTimeEntries', timeEntries);
|
||||
}
|
||||
|
||||
function updateProjectAndTask(projectId: string, taskId: string) {
|
||||
const timeEntries = props.timeEntry.timeEntries as TimeEntry[];
|
||||
timeEntries.forEach((entry) => {
|
||||
updateTimeEntry({
|
||||
...entry,
|
||||
project_id: projectId,
|
||||
task_id: taskId,
|
||||
});
|
||||
entry.project_id = projectId;
|
||||
entry.task_id = taskId;
|
||||
});
|
||||
emit('updateTimeEntries', timeEntries);
|
||||
}
|
||||
|
||||
const expanded = ref(false);
|
||||
@@ -124,6 +87,8 @@ const expanded = ref(false);
|
||||
"></TimeEntryDescriptionInput>
|
||||
</div>
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:showBadgeBorder="false"
|
||||
@changed="updateProjectAndTask"
|
||||
:project="timeEntry.project_id"
|
||||
@@ -157,12 +122,12 @@ const expanded = ref(false);
|
||||
</button>
|
||||
|
||||
<TimeTrackerStartStop
|
||||
@changed="onStartStopClick"
|
||||
@changed="emit('onStartStopClick', timeEntry)"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
class="opacity-20 hidden sm:flex group-hover:opacity-100"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
@delete="
|
||||
deleteTimeEntry
|
||||
emit('deleteTimeEntries', timeEntry.timeEntries)
|
||||
"></TimeEntryMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,7 +136,14 @@ const expanded = ref(false);
|
||||
v-if="expanded"
|
||||
class="w-full border-t border-default-background-separator bg-black/15">
|
||||
<TimeEntryRow
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
indent
|
||||
@updateTimeEntry="
|
||||
(timeEntry) => emit('updateTimeEntries', [timeEntry])
|
||||
"
|
||||
@onStartStopClick="emit('onStartStopClick', subEntry)"
|
||||
@deleteTimeEntry="emit('deleteTimeEntries', [subEntry])"
|
||||
:key="subEntry.id"
|
||||
v-for="subEntry in timeEntry.timeEntries"
|
||||
:time-entry="subEntry"></TimeEntryRow>
|
||||
|
||||
@@ -13,6 +13,13 @@ import InputLabel from '@/Components/InputLabel.vue';
|
||||
import TimePicker from '@/Components/Common/TimePicker.vue';
|
||||
import DatePicker from '@/Components/Common/DatePicker.vue';
|
||||
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 show = defineModel('show', { default: false });
|
||||
@@ -92,6 +99,8 @@ async function submit() {
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
class="mt-1"
|
||||
size="xlarge"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
v-model:project="timeEntry.project_id"
|
||||
v-model:task="
|
||||
timeEntry.task_id
|
||||
|
||||
@@ -9,7 +9,9 @@ defineProps<{
|
||||
end: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['changed']);
|
||||
const emit = defineEmits<{
|
||||
changed: [start: string, end: string | null];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
</script>
|
||||
|
||||
@@ -2,80 +2,45 @@
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
|
||||
import TimeEntryRangeSelector from '@/Components/Common/TimeEntry/TimeEntryRangeSelector.vue';
|
||||
import type { TimeEntry } from '@/utils/api';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { Project, Task, TimeEntry } from '@/utils/api';
|
||||
import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.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 TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
|
||||
|
||||
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
||||
const { stopTimer, updateTimer } = currentTimeEntryStore;
|
||||
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntry;
|
||||
indent?: boolean;
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
}>();
|
||||
|
||||
const { updateTimeEntry, createTimeEntry, fetchTimeEntries } =
|
||||
useTimeEntriesStore();
|
||||
|
||||
async function updateStartEndTime(start: string, end: string | null) {
|
||||
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();
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
onStartStopClick: [];
|
||||
deleteTimeEntry: [];
|
||||
updateTimeEntry: [timeEntry: TimeEntry];
|
||||
}>();
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
updateTimeEntry({ ...props.timeEntry, description });
|
||||
emit('updateTimeEntry', { ...props.timeEntry, description });
|
||||
}
|
||||
|
||||
function updateTimeEntryTags(tags: string[]) {
|
||||
updateTimeEntry({ ...props.timeEntry, tags });
|
||||
emit('updateTimeEntry', { ...props.timeEntry, tags });
|
||||
}
|
||||
|
||||
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) {
|
||||
updateTimeEntry({
|
||||
emit('updateTimeEntry', {
|
||||
...props.timeEntry,
|
||||
project_id: projectId,
|
||||
task_id: taskId,
|
||||
@@ -100,6 +65,8 @@ function updateProjectAndTask(projectId: string, taskId: string) {
|
||||
timeEntry.description
|
||||
"></TimeEntryDescriptionInput>
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:showBadgeBorder="false"
|
||||
@changed="updateProjectAndTask"
|
||||
:project="timeEntry.project_id"
|
||||
@@ -132,12 +99,12 @@ function updateProjectAndTask(projectId: string, taskId: string) {
|
||||
updateStartEndTime
|
||||
"></TimeEntryRowDurationInput>
|
||||
<TimeTrackerStartStop
|
||||
@changed="onStartStopClick"
|
||||
@changed="emit('onStartStopClick')"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
class="opacity-20 hidden sm:flex group-hover:opacity-100"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
@delete="
|
||||
deleteTimeEntry
|
||||
emit('deleteTimeEntry')
|
||||
"></TimeEntryMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,20 +2,15 @@
|
||||
import { ChevronRightIcon } from '@heroicons/vue/16/solid';
|
||||
import Dropdown from '@/Components/Dropdown.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 type { Project, Task } from '@/utils/api';
|
||||
import type { CreateProjectBody, Project, Task } from '@/utils/api';
|
||||
import ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue';
|
||||
import Badge from '@/Components/Common/Badge.vue';
|
||||
import { PlusIcon, PlusCircleIcon } from '@heroicons/vue/16/solid';
|
||||
import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.vue';
|
||||
|
||||
const projectStore = useProjectsStore();
|
||||
const { projects } = storeToRefs(projectStore);
|
||||
const taskStore = useTasksStore();
|
||||
const { tasks } = storeToRefs(taskStore);
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
|
||||
const task = defineModel<string | null>('task', {
|
||||
default: null,
|
||||
@@ -45,10 +40,12 @@ type ProjectWithTasks = {
|
||||
tasks: Task[];
|
||||
};
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showBadgeBorder: boolean;
|
||||
size: 'base' | 'large' | 'xlarge';
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
}>(),
|
||||
{
|
||||
showBadgeBorder: true,
|
||||
@@ -57,14 +54,14 @@ withDefaults(
|
||||
);
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
return projects.value.reduce(
|
||||
return props.projects.reduce(
|
||||
(filtered: ProjectWithTasks[], filterProject) => {
|
||||
const projectNameIncludesSearchTerm = filterProject.name
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
@@ -277,13 +274,13 @@ function moveHighlightDown() {
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
|
||||
const currentProject = computed(() => {
|
||||
return projects.value.find(
|
||||
return props.projects.find(
|
||||
(iteratingProject) => iteratingProject.id === project.value
|
||||
);
|
||||
});
|
||||
|
||||
const currentTask = computed(() => {
|
||||
return tasks.value.find(
|
||||
return props.tasks.find(
|
||||
(iteratingTasks) => iteratingTasks.id === task.value
|
||||
);
|
||||
});
|
||||
@@ -299,7 +296,7 @@ const selectedProjectColor = computed(() => {
|
||||
function selectTask(taskId: string) {
|
||||
task.value = taskId;
|
||||
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;
|
||||
emit('changed', project.value, task.value);
|
||||
}
|
||||
@@ -312,6 +309,14 @@ function selectProject(projectId: string) {
|
||||
}
|
||||
|
||||
const showCreateProject = ref(false);
|
||||
|
||||
const clientsStore = useClientsStore();
|
||||
const { clients } = storeToRefs(clientsStore);
|
||||
|
||||
async function createProject(project: CreateProjectBody, callback: () => void) {
|
||||
await useProjectsStore().createProject(project);
|
||||
callback();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -412,7 +417,10 @@ const showCreateProject = ref(false);
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<ProjectCreateModal v-model:show="showCreateProject"></ProjectCreateModal>
|
||||
<ProjectCreateModal
|
||||
:clients="clients"
|
||||
@submit="createProject"
|
||||
v-model:show="showCreateProject"></ProjectCreateModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { switchOrganization } from '@/utils/useOrganization';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import TimeTrackerRangeSelector from '@/Components/Common/TimeTracker/TimeTrackerRangeSelector.vue';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
|
||||
const page = usePage<{
|
||||
auth: {
|
||||
@@ -34,6 +35,11 @@ const { currentTimeEntry, isActive, now } = storeToRefs(currentTimeEntryStore);
|
||||
const { startLiveTimer, stopLiveTimer, setActiveState } = currentTimeEntryStore;
|
||||
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const projectStore = useProjectsStore();
|
||||
const { projects } = storeToRefs(projectStore);
|
||||
const taskStore = useTasksStore();
|
||||
const { tasks } = storeToRefs(taskStore);
|
||||
|
||||
watch(isActive, () => {
|
||||
if (isActive.value) {
|
||||
startLiveTimer();
|
||||
@@ -133,6 +139,8 @@ function switchToTimeEntryOrganization() {
|
||||
<div class="flex items-center justify-between pl-2">
|
||||
<div class="flex items-center w-[130px] sm:w-auto">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
@changed="updateProject"
|
||||
v-model:project="currentTimeEntry.project_id"
|
||||
v-model:task="
|
||||
|
||||
@@ -12,12 +12,14 @@ import { canCreateProjects } from '@/utils/permissions';
|
||||
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
||||
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import type { CreateProjectBody } from '@/utils/api';
|
||||
|
||||
onMounted(() => {
|
||||
useProjectsStore().fetchProjects();
|
||||
});
|
||||
|
||||
const createProject = ref(false);
|
||||
const { clients } = storeToRefs(useClientsStore());
|
||||
const showCreateProjectModal = ref(false);
|
||||
|
||||
const activeTab = ref<'active' | 'archived'>('active');
|
||||
|
||||
@@ -25,6 +27,11 @@ function isActiveTab(tab: string) {
|
||||
return activeTab.value === tab;
|
||||
}
|
||||
|
||||
async function createProject(project: CreateProjectBody, callback: () => void) {
|
||||
await useProjectsStore().createProject(project);
|
||||
callback();
|
||||
}
|
||||
|
||||
const { projects } = storeToRefs(useProjectsStore());
|
||||
|
||||
const shownProjects = computed(() => {
|
||||
@@ -59,11 +66,13 @@ const shownProjects = computed(() => {
|
||||
<SecondaryButton
|
||||
v-if="canCreateProjects()"
|
||||
:icon="PlusIcon"
|
||||
@click="createProject = true"
|
||||
@click="showCreateProjectModal = true"
|
||||
>Create Project
|
||||
</SecondaryButton>
|
||||
<ProjectCreateModal
|
||||
v-model:show="createProject"></ProjectCreateModal>
|
||||
:clients="clients"
|
||||
@submit="createProject"
|
||||
v-model:show="showCreateProjectModal"></ProjectCreateModal>
|
||||
</MainContainer>
|
||||
<ProjectTable :projects="shownProjects"></ProjectTable>
|
||||
</AppLayout>
|
||||
|
||||
@@ -19,14 +19,51 @@ import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import TimeEntryCreateModal from '@/Components/Common/TimeEntry/TimeEntryCreateModal.vue';
|
||||
import TimeEntryAggregateRow from '@/Components/Common/TimeEntry/TimeEntryAggregateRow.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 { 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 loadMoreContainer = ref<HTMLDivElement | null>(null);
|
||||
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) => {
|
||||
if (
|
||||
isVisible &&
|
||||
@@ -45,6 +82,10 @@ onMounted(async () => {
|
||||
const groupedTimeEntries = computed(() => {
|
||||
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
|
||||
for (const entry of timeEntries.value) {
|
||||
// skip current time entry
|
||||
if (entry.end === null) {
|
||||
continue;
|
||||
}
|
||||
const oldEntries =
|
||||
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)];
|
||||
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)] = [
|
||||
@@ -104,6 +145,10 @@ const groupedTimeEntries = computed(() => {
|
||||
return groupedEntriesByDayAndType;
|
||||
});
|
||||
const showManualTimeEntryModal = ref(false);
|
||||
const projectStore = useProjectsStore();
|
||||
const { projects } = storeToRefs(projectStore);
|
||||
const taskStore = useTasksStore();
|
||||
const { tasks } = storeToRefs(taskStore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -131,11 +176,23 @@ const showManualTimeEntryModal = ref(false);
|
||||
<TimeEntryRowHeading :date="key"></TimeEntryRowHeading>
|
||||
<template v-for="entry in value" :key="entry.id">
|
||||
<TimeEntryAggregateRow
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
@onStartStopClick="onStartStopClick"
|
||||
@updateTimeEntries="updateTimeEntries"
|
||||
@deleteTimeEntries="deleteTimeEntries"
|
||||
v-if="
|
||||
'timeEntries' in entry && entry.timeEntries.length > 1
|
||||
"
|
||||
: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>
|
||||
</div>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user