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();
|
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
|
// Export Tests
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
createTimeEntryWithBillableStatusViaApi,
|
createTimeEntryWithBillableStatusViaApi,
|
||||||
createTagViaApi,
|
createTagViaApi,
|
||||||
createReportViaApi,
|
createReportViaApi,
|
||||||
|
updateOrganizationSettingViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
import {
|
import {
|
||||||
goToReporting,
|
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();
|
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 }) => {
|
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 page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value');
|
||||||
await expect(page.getByText('No time entries found').first()).toBeVisible();
|
await expect(page.getByText('No time entries found').first()).toBeVisible();
|
||||||
|
|||||||
@@ -240,7 +240,8 @@ const groupedPieChartData = computed(() => {
|
|||||||
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
|
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
|
||||||
const name = getNameForReportingRowEntry(
|
const name = getNameForReportingRowEntry(
|
||||||
entry.key,
|
entry.key,
|
||||||
aggregatedTableTimeEntries.value?.grouped_type ?? null
|
aggregatedTableTimeEntries.value?.grouped_type ?? null,
|
||||||
|
organization?.value?.date_format
|
||||||
);
|
);
|
||||||
let color = getRandomColorWithSeed(entry.key ?? 'none');
|
let color = getRandomColorWithSeed(entry.key ?? 'none');
|
||||||
if (
|
if (
|
||||||
@@ -255,11 +256,7 @@ const groupedPieChartData = computed(() => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
value: entry.seconds,
|
value: entry.seconds,
|
||||||
name:
|
name: name ?? '',
|
||||||
getNameForReportingRowEntry(
|
|
||||||
entry.key,
|
|
||||||
aggregatedTableTimeEntries.value?.grouped_type ?? null
|
|
||||||
) ?? '',
|
|
||||||
color: color,
|
color: color,
|
||||||
};
|
};
|
||||||
}) ?? []
|
}) ?? []
|
||||||
@@ -269,18 +266,25 @@ const groupedPieChartData = computed(() => {
|
|||||||
const tableData = computed(() => {
|
const tableData = computed(() => {
|
||||||
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
|
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
|
||||||
return {
|
return {
|
||||||
|
key: entry.key,
|
||||||
seconds: entry.seconds,
|
seconds: entry.seconds,
|
||||||
cost: entry.cost,
|
cost: entry.cost,
|
||||||
description: getNameForReportingRowEntry(
|
description: getNameForReportingRowEntry(
|
||||||
entry.key,
|
entry.key,
|
||||||
aggregatedTableTimeEntries.value?.grouped_type ?? null
|
aggregatedTableTimeEntries.value?.grouped_type ?? null,
|
||||||
|
organization?.value?.date_format
|
||||||
),
|
),
|
||||||
grouped_data:
|
grouped_data:
|
||||||
entry.grouped_data?.map((el) => {
|
entry.grouped_data?.map((el) => {
|
||||||
return {
|
return {
|
||||||
|
key: el.key,
|
||||||
seconds: el.seconds,
|
seconds: el.seconds,
|
||||||
cost: el.cost,
|
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
|
<ReportingRow
|
||||||
v-for="entry in tableData"
|
v-for="entry in tableData"
|
||||||
:key="entry.description ?? 'none'"
|
:key="entry.key ?? 'none'"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
:type="aggregatedTableTimeEntries.grouped_type"
|
|
||||||
:show-cost="showBillableRate"
|
:show-cost="showBillableRate"
|
||||||
:entry="entry"></ReportingRow>
|
:entry="entry"></ReportingRow>
|
||||||
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
|
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type AggregatedGroupedData = GroupedData & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type GroupedData = {
|
type GroupedData = {
|
||||||
|
key: string | null;
|
||||||
seconds: number;
|
seconds: number;
|
||||||
cost: number | null;
|
cost: number | null;
|
||||||
description: string | null | undefined;
|
description: string | null | undefined;
|
||||||
@@ -72,7 +73,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
|||||||
:style="`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`">
|
:style="`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`">
|
||||||
<ReportingRow
|
<ReportingRow
|
||||||
v-for="subEntry in entry.grouped_data"
|
v-for="subEntry in entry.grouped_data"
|
||||||
:key="subEntry.description ?? 'none'"
|
:key="subEntry.key ?? 'none'"
|
||||||
:currency="props.currency"
|
:currency="props.currency"
|
||||||
:show-cost="showCost"
|
:show-cost="showCost"
|
||||||
indent
|
indent
|
||||||
|
|||||||
@@ -95,20 +95,24 @@ const tableData = computed(() => {
|
|||||||
return (
|
return (
|
||||||
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
|
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
|
||||||
return {
|
return {
|
||||||
|
key: entry.key,
|
||||||
seconds: entry.seconds,
|
seconds: entry.seconds,
|
||||||
cost: entry.cost,
|
cost: entry.cost,
|
||||||
description: getNameForReportingRowEntry(
|
description: getNameForReportingRowEntry(
|
||||||
entry.key,
|
entry.key,
|
||||||
aggregatedTableTimeEntries.value?.grouped_type ?? null
|
aggregatedTableTimeEntries.value?.grouped_type ?? null,
|
||||||
|
organization?.value?.date_format
|
||||||
),
|
),
|
||||||
grouped_data:
|
grouped_data:
|
||||||
entry.grouped_data?.map((el) => {
|
entry.grouped_data?.map((el) => {
|
||||||
return {
|
return {
|
||||||
|
key: el.key,
|
||||||
seconds: el.seconds,
|
seconds: el.seconds,
|
||||||
cost: el.cost,
|
cost: el.cost,
|
||||||
description: getNameForReportingRowEntry(
|
description: getNameForReportingRowEntry(
|
||||||
el.key,
|
el.key,
|
||||||
entry.grouped_type ?? null
|
entry.grouped_type ?? null,
|
||||||
|
organization?.value?.date_format
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}) ?? [],
|
}) ?? [],
|
||||||
@@ -164,7 +168,7 @@ const showBillableRate = computed(() => {
|
|||||||
">
|
">
|
||||||
<ReportingRow
|
<ReportingRow
|
||||||
v-for="entry in tableData"
|
v-for="entry in tableData"
|
||||||
:key="entry.description ?? 'none'"
|
:key="entry.key ?? 'none'"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
:show-cost="showBillableRate"
|
:show-cost="showBillableRate"
|
||||||
:entry="entry"></ReportingRow>
|
:entry="entry"></ReportingRow>
|
||||||
|
|||||||
@@ -116,25 +116,43 @@ const subGroup = computed(() => {
|
|||||||
}
|
}
|
||||||
return 'project';
|
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(() => {
|
const groupedPieChartData = computed(() => {
|
||||||
return (
|
return (
|
||||||
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
|
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 {
|
return {
|
||||||
value: entry.seconds,
|
value: entry.seconds,
|
||||||
name:
|
name: name,
|
||||||
emptyPlaceholder[
|
|
||||||
aggregatedTableTimeEntries.value?.grouped_type ?? 'project'
|
|
||||||
] ?? '',
|
|
||||||
color: '#CCCCCC',
|
color: '#CCCCCC',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
value: entry.seconds,
|
value: entry.seconds,
|
||||||
name: entry.description,
|
name: name,
|
||||||
color: entry.color ?? getRandomColorWithSeed(entry.description ?? 'none'),
|
color: entry.color ?? getRandomColorWithSeed(name),
|
||||||
};
|
};
|
||||||
}) ?? []
|
}) ?? []
|
||||||
);
|
);
|
||||||
@@ -143,21 +161,25 @@ const groupedPieChartData = computed(() => {
|
|||||||
const tableData = computed(() => {
|
const tableData = computed(() => {
|
||||||
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
|
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
|
||||||
return {
|
return {
|
||||||
|
key: entry.key,
|
||||||
seconds: entry.seconds,
|
seconds: entry.seconds,
|
||||||
cost: entry.cost,
|
cost: entry.cost,
|
||||||
description:
|
description: resolveLabel(
|
||||||
entry.description ??
|
entry.description,
|
||||||
emptyPlaceholder[aggregatedTableTimeEntries.value?.grouped_type ?? 'project'] ??
|
entry.key,
|
||||||
'',
|
aggregatedTableTimeEntries.value?.grouped_type ?? 'project'
|
||||||
|
),
|
||||||
grouped_data:
|
grouped_data:
|
||||||
entry.grouped_data?.map((el) => {
|
entry.grouped_data?.map((el) => {
|
||||||
return {
|
return {
|
||||||
|
key: el.key,
|
||||||
seconds: el.seconds,
|
seconds: el.seconds,
|
||||||
cost: el.cost,
|
cost: el.cost,
|
||||||
description:
|
description: resolveLabel(
|
||||||
el.description ??
|
el.description,
|
||||||
emptyPlaceholder[entry.grouped_type ?? 'project'] ??
|
el.key,
|
||||||
'',
|
entry.grouped_type ?? 'project'
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}) ?? [],
|
}) ?? [],
|
||||||
};
|
};
|
||||||
@@ -219,7 +241,7 @@ onMounted(async () => {
|
|||||||
">
|
">
|
||||||
<ReportingRow
|
<ReportingRow
|
||||||
v-for="entry in tableData"
|
v-for="entry in tableData"
|
||||||
:key="entry.description ?? 'none'"
|
:key="entry.key ?? 'none'"
|
||||||
:currency="reportCurrency"
|
:currency="reportCurrency"
|
||||||
:currency-format="reportCurrencyFormat"
|
:currency-format="reportCurrencyFormat"
|
||||||
:show-cost="true"
|
:show-cost="true"
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
|
|||||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||||
import { useTagsQuery } from '@/utils/useTagsQuery';
|
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||||
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
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 { Coffee } from '@lucide/vue';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import { type DateFormat, formatDate } from '@/packages/ui/src/utils/time';
|
||||||
|
|
||||||
export type GroupingOption =
|
export type GroupingOption =
|
||||||
| 'project'
|
| 'project'
|
||||||
@@ -19,7 +20,8 @@ export type GroupingOption =
|
|||||||
| 'client'
|
| 'client'
|
||||||
| 'description'
|
| 'description'
|
||||||
| 'tag'
|
| 'tag'
|
||||||
| 'type';
|
| 'type'
|
||||||
|
| 'day';
|
||||||
|
|
||||||
export const useReportingStore = defineStore('reporting', () => {
|
export const useReportingStore = defineStore('reporting', () => {
|
||||||
// Cache query composables to avoid creating new subscriptions on every call
|
// Cache query composables to avoid creating new subscriptions on every call
|
||||||
@@ -40,7 +42,11 @@ export const useReportingStore = defineStore('reporting', () => {
|
|||||||
type: 'Work time',
|
type: 'Work time',
|
||||||
} as Record<string, string>;
|
} 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) {
|
if (type === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -76,6 +82,9 @@ export const useReportingStore = defineStore('reporting', () => {
|
|||||||
if (type === 'type') {
|
if (type === 'type') {
|
||||||
return key === 'break' ? 'Break' : 'Work time';
|
return key === 'break' ? 'Break' : 'Work time';
|
||||||
}
|
}
|
||||||
|
if (type === 'day') {
|
||||||
|
return formatDate(key, dateFormat);
|
||||||
|
}
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +133,11 @@ export const useReportingStore = defineStore('reporting', () => {
|
|||||||
value: 'tag',
|
value: 'tag',
|
||||||
icon: DocumentTextIcon,
|
icon: DocumentTextIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Date',
|
||||||
|
value: 'day',
|
||||||
|
icon: CalendarIcon,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user