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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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); 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 @@ 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, + }; +});