mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-20 22:22:16 +01:00
Add "Date" grouping option to reporting
This commit is contained in:
@@ -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
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {
|
||||
">
|
||||
<ReportingRow
|
||||
v-for="entry in tableData"
|
||||
:key="entry.description ?? 'none'"
|
||||
:key="entry.key ?? 'none'"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:type="aggregatedTableTimeEntries.grouped_type"
|
||||
:show-cost="showBillableRate"
|
||||
:entry="entry"></ReportingRow>
|
||||
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
|
||||
|
||||
@@ -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<ComputedRef<Organization>>('organization');
|
||||
:style="`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`">
|
||||
<ReportingRow
|
||||
v-for="subEntry in entry.grouped_data"
|
||||
:key="subEntry.description ?? 'none'"
|
||||
:key="subEntry.key ?? 'none'"
|
||||
:currency="props.currency"
|
||||
:show-cost="showCost"
|
||||
indent
|
||||
|
||||
@@ -95,20 +95,24 @@ 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 ?? null
|
||||
entry.grouped_type ?? null,
|
||||
organization?.value?.date_format
|
||||
),
|
||||
};
|
||||
}) ?? [],
|
||||
@@ -164,7 +168,7 @@ const showBillableRate = computed(() => {
|
||||
">
|
||||
<ReportingRow
|
||||
v-for="entry in tableData"
|
||||
:key="entry.description ?? 'none'"
|
||||
:key="entry.key ?? 'none'"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:show-cost="showBillableRate"
|
||||
:entry="entry"></ReportingRow>
|
||||
|
||||
@@ -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 () => {
|
||||
">
|
||||
<ReportingRow
|
||||
v-for="entry in tableData"
|
||||
:key="entry.description ?? 'none'"
|
||||
:key="entry.key ?? 'none'"
|
||||
:currency="reportCurrency"
|
||||
:currency-format="reportCurrencyFormat"
|
||||
:show-cost="true"
|
||||
|
||||
@@ -7,9 +7,10 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
|
||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
|
||||
import { CalendarIcon, DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { type DateFormat, formatDate } from '@/packages/ui/src/utils/time';
|
||||
|
||||
export type GroupingOption =
|
||||
| 'project'
|
||||
@@ -19,7 +20,8 @@ export type GroupingOption =
|
||||
| 'client'
|
||||
| 'description'
|
||||
| 'tag'
|
||||
| 'type';
|
||||
| 'type'
|
||||
| 'day';
|
||||
|
||||
export const useReportingStore = defineStore('reporting', () => {
|
||||
// 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<string, string>;
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user