diff --git a/e2e/reporting.spec.ts b/e2e/reporting.spec.ts index 50b773da..1c855dbd 100644 --- a/e2e/reporting.spec.ts +++ b/e2e/reporting.spec.ts @@ -841,6 +841,50 @@ test('test that setting group by to current sub group triggers sub group fallbac await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible(); }); +test('test that group by date groups the report by day and formats the date labels with organization settings', async ({ + page, + ctx, +}) => { + await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' }); + + await createTimeEntryViaApi(ctx, { + description: 'Entry for group by date', + duration: '1h', + }); + + // Go to reporting page + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Find the "Group by" selects within the reporting table + const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox'); + + // Default state: group=Project + await groupBySelects.filter({ hasText: 'Project' }).first().click(); + + const [aggregateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && + response.url().includes('group=day') && + response.status() === 200 + ), + page.getByRole('option', { name: 'Date', exact: true }).click(), + ]); + + // Verify the API request contains the correct group parameter + const requestUrl = new URL(aggregateResponse.url()); + expect(requestUrl.searchParams.get('group')).toBe('day'); + + // The row label is rendered in the organization date format (D.M.YYYY) + await expect( + page.getByTestId('reporting_view').getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/) + ).toBeVisible(); + await expect(page.getByTestId('reporting_view').getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount( + 0 + ); +}); + // ────────────────────────────────────────────────── // Export Tests // ────────────────────────────────────────────────── diff --git a/e2e/shared-reports.spec.ts b/e2e/shared-reports.spec.ts index ff262b17..52541705 100644 --- a/e2e/shared-reports.spec.ts +++ b/e2e/shared-reports.spec.ts @@ -16,6 +16,7 @@ import { createTimeEntryWithBillableStatusViaApi, createTagViaApi, createReportViaApi, + updateOrganizationSettingViaApi, } from './utils/api'; import { goToReporting, @@ -68,6 +69,42 @@ test('test that saving a report creates a shared report and its shareable link s await expect(page.getByText('Total')).toBeVisible(); }); +test('test that a shared report grouped by date shows date labels formatted by the organization setting', async ({ + page, + ctx, +}) => { + const reportName = 'DateGroupReport ' + Math.floor(Math.random() * 10000); + + await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' }); + await createTimeEntryViaApi(ctx, { + description: 'Entry for date grouping', + duration: '1h', + }); + + await goToReporting(page); + + // Switch the grouping to "Date" + const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox'); + await groupBySelects.filter({ hasText: 'Project' }).first().click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && + response.url().includes('group=day') && + response.status() === 200 + ), + page.getByRole('option', { name: 'Date', exact: true }).click(), + ]); + + const { shareableLink } = await saveAsSharedReport(page, reportName); + + // Verify row labels are formatted correctly + await page.goto(shareableLink); + await expect(page.getByText('Total')).toBeVisible(); + await expect(page.getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/)).toBeVisible(); + await expect(page.getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(0); +}); + test('test that shared report with invalid secret shows no data', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value'); await expect(page.getByText('No time entries found').first()).toBeVisible(); diff --git a/resources/js/Components/Common/Reporting/ReportingOverview.vue b/resources/js/Components/Common/Reporting/ReportingOverview.vue index 04fcefa9..444dd716 100644 --- a/resources/js/Components/Common/Reporting/ReportingOverview.vue +++ b/resources/js/Components/Common/Reporting/ReportingOverview.vue @@ -240,7 +240,8 @@ const groupedPieChartData = computed(() => { aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { const name = getNameForReportingRowEntry( entry.key, - aggregatedTableTimeEntries.value?.grouped_type ?? null + aggregatedTableTimeEntries.value?.grouped_type ?? null, + organization?.value?.date_format ); let color = getRandomColorWithSeed(entry.key ?? 'none'); if ( @@ -255,11 +256,7 @@ const groupedPieChartData = computed(() => { } return { value: entry.seconds, - name: - getNameForReportingRowEntry( - entry.key, - aggregatedTableTimeEntries.value?.grouped_type ?? null - ) ?? '', + name: name ?? '', color: color, }; }) ?? [] @@ -269,18 +266,25 @@ const groupedPieChartData = computed(() => { const tableData = computed(() => { return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { return { + key: entry.key, seconds: entry.seconds, cost: entry.cost, description: getNameForReportingRowEntry( entry.key, - aggregatedTableTimeEntries.value?.grouped_type ?? null + aggregatedTableTimeEntries.value?.grouped_type ?? null, + organization?.value?.date_format ), grouped_data: entry.grouped_data?.map((el) => { return { + key: el.key, seconds: el.seconds, cost: el.cost, - description: getNameForReportingRowEntry(el.key, entry.grouped_type), + description: getNameForReportingRowEntry( + el.key, + entry.grouped_type, + organization?.value?.date_format + ), }; }) ?? [], }; @@ -421,9 +425,8 @@ const tableData = computed(() => { ">
diff --git a/resources/js/Components/Common/Reporting/ReportingRow.vue b/resources/js/Components/Common/Reporting/ReportingRow.vue index cb339fe0..807dcdcb 100644 --- a/resources/js/Components/Common/Reporting/ReportingRow.vue +++ b/resources/js/Components/Common/Reporting/ReportingRow.vue @@ -11,6 +11,7 @@ type AggregatedGroupedData = GroupedData & { }; type GroupedData = { + key: string | null; seconds: number; cost: number | null; description: string | null | undefined; @@ -72,7 +73,7 @@ const organization = inject>('organization'); :style="`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`"> { return ( aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { return { + key: entry.key, seconds: entry.seconds, cost: entry.cost, description: getNameForReportingRowEntry( entry.key, - aggregatedTableTimeEntries.value?.grouped_type ?? null + aggregatedTableTimeEntries.value?.grouped_type ?? null, + organization?.value?.date_format ), grouped_data: entry.grouped_data?.map((el) => { return { + key: el.key, seconds: el.seconds, cost: el.cost, description: getNameForReportingRowEntry( el.key, - entry.grouped_type ?? null + entry.grouped_type ?? null, + organization?.value?.date_format ), }; }) ?? [], @@ -164,7 +168,7 @@ const showBillableRate = computed(() => { "> diff --git a/resources/js/Pages/SharedReport.vue b/resources/js/Pages/SharedReport.vue index 3eb0b188..8acf115e 100644 --- a/resources/js/Pages/SharedReport.vue +++ b/resources/js/Pages/SharedReport.vue @@ -116,25 +116,43 @@ const subGroup = computed(() => { } return 'project'; }); -const { emptyPlaceholder } = useReportingStore(); +const { emptyPlaceholder, getNameForReportingRowEntry } = useReportingStore(); + +/** + * The public report endpoint has no descriptor for time group types, so their labels are + * derived from the raw group key. + */ +function resolveLabel( + description: string | null | undefined, + key: string | null | undefined, + groupedType: string +) { + if (description !== null && description !== undefined) { + return description; + } + return ( + getNameForReportingRowEntry(key ?? null, groupedType, reportDateFormat.value) ?? + emptyPlaceholder[groupedType] ?? + '' + ); +} const groupedPieChartData = computed(() => { return ( aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { - if (entry.description === null) { + const groupedType = aggregatedTableTimeEntries.value?.grouped_type ?? 'project'; + const name = resolveLabel(entry.description, entry.key, groupedType); + if (name === emptyPlaceholder[groupedType]) { return { value: entry.seconds, - name: - emptyPlaceholder[ - aggregatedTableTimeEntries.value?.grouped_type ?? 'project' - ] ?? '', + name: name, color: '#CCCCCC', }; } return { value: entry.seconds, - name: entry.description, - color: entry.color ?? getRandomColorWithSeed(entry.description ?? 'none'), + name: name, + color: entry.color ?? getRandomColorWithSeed(name), }; }) ?? [] ); @@ -143,21 +161,25 @@ const groupedPieChartData = computed(() => { const tableData = computed(() => { return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { return { + key: entry.key, seconds: entry.seconds, cost: entry.cost, - description: - entry.description ?? - emptyPlaceholder[aggregatedTableTimeEntries.value?.grouped_type ?? 'project'] ?? - '', + description: resolveLabel( + entry.description, + entry.key, + aggregatedTableTimeEntries.value?.grouped_type ?? 'project' + ), grouped_data: entry.grouped_data?.map((el) => { return { + key: el.key, seconds: el.seconds, cost: el.cost, - description: - el.description ?? - emptyPlaceholder[entry.grouped_type ?? 'project'] ?? - '', + description: resolveLabel( + el.description, + el.key, + entry.grouped_type ?? 'project' + ), }; }) ?? [], }; @@ -219,7 +241,7 @@ onMounted(async () => { "> { // Cache query composables to avoid creating new subscriptions on every call @@ -40,7 +42,11 @@ export const useReportingStore = defineStore('reporting', () => { type: 'Work time', } as Record; - function getNameForReportingRowEntry(key: string | null, type: string | null) { + function getNameForReportingRowEntry( + key: string | null, + type: string | null, + dateFormat?: DateFormat + ) { if (type === null) { return null; } @@ -76,6 +82,9 @@ export const useReportingStore = defineStore('reporting', () => { if (type === 'type') { return key === 'break' ? 'Break' : 'Work time'; } + if (type === 'day') { + return formatDate(key, dateFormat); + } return key; } @@ -124,6 +133,11 @@ export const useReportingStore = defineStore('reporting', () => { value: 'tag', icon: DocumentTextIcon, }, + { + label: 'Date', + value: 'day', + icon: CalendarIcon, + }, ]; return {