diff --git a/e2e/reporting.spec.ts b/e2e/reporting.spec.ts new file mode 100644 index 00000000..102a54c7 --- /dev/null +++ b/e2e/reporting.spec.ts @@ -0,0 +1,5 @@ +// TODO: Test filter + +// TODO: Test date range + +// TODO: Test grouping and sub-grouping diff --git a/resources/js/Components/Common/DateRangePicker.vue b/resources/js/Components/Common/DateRangePicker.vue new file mode 100644 index 00000000..5b39535b --- /dev/null +++ b/resources/js/Components/Common/DateRangePicker.vue @@ -0,0 +1,39 @@ + + + + + + + + + {{ formatDate(start) }} + - + {{ formatDate(end) }} + + + + + + + Start Date + + + + End Date + + + + + + + + diff --git a/resources/js/Components/Common/MultiselectDropdown.vue b/resources/js/Components/Common/MultiselectDropdown.vue new file mode 100644 index 00000000..6176cbc6 --- /dev/null +++ b/resources/js/Components/Common/MultiselectDropdown.vue @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + diff --git a/resources/js/Components/Common/Tag/TagDropdownItem.vue b/resources/js/Components/Common/MultiselectDropdownItem.vue similarity index 95% rename from resources/js/Components/Common/Tag/TagDropdownItem.vue rename to resources/js/Components/Common/MultiselectDropdownItem.vue index 74d8a79f..d8702d26 100644 --- a/resources/js/Components/Common/Tag/TagDropdownItem.vue +++ b/resources/js/Components/Common/MultiselectDropdownItem.vue @@ -12,7 +12,7 @@ const iconClasses = computed(() => { if (props.selected) { return 'text-accent-200'; } else { - return 'text-card-border'; + return 'text-white/10'; } }); diff --git a/resources/js/Components/Common/Reporting/ReportingChart.vue b/resources/js/Components/Common/Reporting/ReportingChart.vue new file mode 100644 index 00000000..a8cb50eb --- /dev/null +++ b/resources/js/Components/Common/Reporting/ReportingChart.vue @@ -0,0 +1,165 @@ + + + + + + + + No time entries found + + Try to change the filters and time range + + + + + diff --git a/resources/js/Components/Common/Reporting/ReportingFilterBadge.vue b/resources/js/Components/Common/Reporting/ReportingFilterBadge.vue new file mode 100644 index 00000000..54324303 --- /dev/null +++ b/resources/js/Components/Common/Reporting/ReportingFilterBadge.vue @@ -0,0 +1,40 @@ + + + + + + {{ title }} + + {{ count }} + + + + + diff --git a/resources/js/Components/Common/Reporting/ReportingGroupBySelect.vue b/resources/js/Components/Common/Reporting/ReportingGroupBySelect.vue new file mode 100644 index 00000000..e4cc576a --- /dev/null +++ b/resources/js/Components/Common/Reporting/ReportingGroupBySelect.vue @@ -0,0 +1,59 @@ + + + + + + + + {{ title }} + + + + + + diff --git a/resources/js/Components/Common/Reporting/ReportingRow.vue b/resources/js/Components/Common/Reporting/ReportingRow.vue new file mode 100644 index 00000000..ab6cc3fa --- /dev/null +++ b/resources/js/Components/Common/Reporting/ReportingRow.vue @@ -0,0 +1,105 @@ + + + + + + + {{ entry.grouped_data?.length }} + + + {{ entry.key ? getNameForKey(entry.key) : emptyPlaceholder }} + + + + {{ formatHumanReadableDuration(entry.seconds) }} + + + {{ formatMoney(entry.cost) }} + + + + + + + + diff --git a/resources/js/Components/Common/SelectDropdown.vue b/resources/js/Components/Common/SelectDropdown.vue new file mode 100644 index 00000000..8b7adf9c --- /dev/null +++ b/resources/js/Components/Common/SelectDropdown.vue @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + diff --git a/resources/js/Components/Common/SelectDropdownItem.vue b/resources/js/Components/Common/SelectDropdownItem.vue new file mode 100644 index 00000000..ae837d79 --- /dev/null +++ b/resources/js/Components/Common/SelectDropdownItem.vue @@ -0,0 +1,23 @@ + + + + + {{ name }} + + + + diff --git a/resources/js/Components/Common/Tag/TagDropdown.vue b/resources/js/Components/Common/Tag/TagDropdown.vue index 56c8e1b8..be97a59b 100644 --- a/resources/js/Components/Common/Tag/TagDropdown.vue +++ b/resources/js/Components/Common/Tag/TagDropdown.vue @@ -2,9 +2,10 @@ import { PlusCircleIcon } from '@heroicons/vue/20/solid'; import Dropdown from '@/Components/Dropdown.vue'; import { type Component, computed, nextTick, ref, watch } from 'vue'; -import TagDropdownItem from '@/Components/Common/Tag/TagDropdownItem.vue'; import { useTagsStore } from '@/utils/useTags'; import { storeToRefs } from 'pinia'; +import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue'; +import MultiselectDropdownItem from '@/Components/Common/MultiselectDropdownItem.vue'; const tagsStore = useTagsStore(); const { tags } = storeToRefs(tagsStore); @@ -47,6 +48,11 @@ watch(open, (isOpen) => { } return model.value.includes(a.id) ? -1 : 1; }); + nextTick(() => { + if (filteredTags.value.length > 0) { + highlightedItemId.value = filteredTags.value[0].id; + } + }); } }); @@ -96,17 +102,15 @@ function updateSearchValue(event: Event) { } } -const emit = defineEmits(['update:modelValue', 'changed']); +const emit = defineEmits(['update:modelValue', 'changed', 'submit']); function toggleTag(newValue: string) { if (model.value.includes(newValue)) { - model.value = model.value.filter((id) => id !== newValue); + model.value = [...model.value].filter((id) => id !== newValue); } else { - model.value.push(newValue); + model.value = [...model.value, newValue]; } - nextTick(() => { - emit('changed'); - }); + emit('changed'); } function moveHighlightUp() { @@ -142,10 +146,17 @@ const highlightedItemId = ref(null); const highlightedItem = computed(() => { return tags.value.find((tag) => tag.id === highlightedItemId.value); }); + +const showCreateTagModal = ref(false); - + + @@ -159,20 +170,20 @@ const highlightedItem = computed(() => { @keydown.down.prevent="moveHighlightDown" ref="searchInput" class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full" - placeholder="Search for a tag..." /> + placeholder="Search for a Tag..." /> + class="bg-card-background-active rounded-b-lg"> + class="text-white flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t border-card-background-separator"> Add "{{ searchValue }}" as a new Tag - + { }" data-testid="tag_dropdown_entries" :data-tag-id="tag.id"> - + :name="tag.name"> + + + + + Create new Tag + diff --git a/resources/js/Components/Common/Task/TaskMultiselectDropdown.vue b/resources/js/Components/Common/Task/TaskMultiselectDropdown.vue new file mode 100644 index 00000000..14ff161f --- /dev/null +++ b/resources/js/Components/Common/Task/TaskMultiselectDropdown.vue @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/resources/js/Pages/Reporting.vue b/resources/js/Pages/Reporting.vue index e804828b..04c5e1a8 100644 --- a/resources/js/Pages/Reporting.vue +++ b/resources/js/Pages/Reporting.vue @@ -1,13 +1,296 @@ - Reporting is coming soon™ + class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center"> + + + + + + + + + Filters + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Group by + + and + + + + + Name + Duration + Cost + + + + + + Total + + + {{ + formatHumanReadableDuration( + aggregatedTableTimeEntries.seconds + ) + }} + + + {{ + formatMoney( + aggregatedTableTimeEntries.cost + ) + }} + + + + + + No time entries found + + Try to change the filters and time range + + + + + diff --git a/resources/js/utils/api.ts b/resources/js/utils/api.ts index f79a150b..94fae14a 100644 --- a/resources/js/utils/api.ts +++ b/resources/js/utils/api.ts @@ -2,6 +2,7 @@ import type { ApiOf, ZodiosResponseByAlias, ZodiosBodyByAlias, + ZodiosQueryParamsByAlias, } from '@zodios/core'; import { api } from '../../../openapi.json.client'; @@ -84,3 +85,16 @@ export type ImportType = ZodiosResponseByAlias< 'getImporters' >['data'][0]; export type ImportReport = ZodiosResponseByAlias; + +export type ReportingResponse = ZodiosResponseByAlias< + SolidTimeApi, + 'getAggregatedTimeEntries' +>; + +export type AggregatedTimeEntries = ReportingResponse['data']; +export type GroupedDataEntries = ReportingResponse['data']['grouped_data']; + +export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias< + SolidTimeApi, + 'getAggregatedTimeEntries' +>; diff --git a/resources/js/utils/useReporting.ts b/resources/js/utils/useReporting.ts new file mode 100644 index 00000000..932ff986 --- /dev/null +++ b/resources/js/utils/useReporting.ts @@ -0,0 +1,68 @@ +import { defineStore } from 'pinia'; +import { api } from '../../../openapi.json.client'; +import { computed, ref } from 'vue'; +import type { + AggregatedTimeEntries, + AggregatedTimeEntriesQueryParams, + ReportingResponse, +} from '@/utils/api'; +import { getCurrentOrganizationId } from '@/utils/useUser'; +import { useNotificationsStore } from '@/utils/notification'; + +export const useReportingStore = defineStore('reporting', () => { + const reportingGraphResponse = ref(null); + const reportingTableResponse = ref(null); + + const { handleApiRequestNotifications } = useNotificationsStore(); + + async function fetchGraphReporting( + params: AggregatedTimeEntriesQueryParams + ) { + const organization = getCurrentOrganizationId(); + if (organization) { + reportingGraphResponse.value = await handleApiRequestNotifications( + api.getAggregatedTimeEntries({ + params: { + organization: organization, + }, + queries: params, + }), + undefined, + 'Failed to fetch reporting data' + ); + } + } + + async function fetchTableReporting( + params: AggregatedTimeEntriesQueryParams + ) { + const organization = getCurrentOrganizationId(); + if (organization) { + reportingTableResponse.value = await handleApiRequestNotifications( + api.getAggregatedTimeEntries({ + params: { + organization: organization, + }, + queries: params, + }), + undefined, + 'Failed to fetch reporting data' + ); + } + } + + const aggregatedGraphTimeEntries = computed(() => { + return reportingGraphResponse.value?.data as AggregatedTimeEntries; + }); + + const aggregatedTableTimeEntries = computed(() => { + return reportingTableResponse.value?.data as AggregatedTimeEntries; + }); + + return { + aggregatedGraphTimeEntries, + fetchGraphReporting, + fetchTableReporting, + aggregatedTableTimeEntries, + }; +});
+ No time entries found +
Try to change the filters and time range