mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-13 10:42:16 +01:00
Compare commits
3 Commits
v0.7.0
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d80896e0b8 | ||
|
|
b796d232f5 | ||
|
|
26c50867b3 |
@@ -1,5 +1,186 @@
|
|||||||
// TODO: Test filter
|
import { expect, Page } from '@playwright/test';
|
||||||
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
import { test } from '../playwright/fixtures';
|
||||||
|
|
||||||
// TODO: Test date range
|
|
||||||
|
|
||||||
// TODO: Test grouping and sub-grouping
|
|
||||||
|
async function goToTimeOverview(page: Page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToReporting(page: Page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToReportingDetailed(page: Page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) {
|
||||||
|
// First create the project through the Projects page
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||||
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
|
await page.getByLabel('Project Name').fill(projectName);
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
|
||||||
|
|
||||||
|
// Wait for the project to be created and visible in the list
|
||||||
|
await page.getByText(projectName).waitFor({ state: 'visible' });
|
||||||
|
|
||||||
|
// Then create the time entry
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
|
|
||||||
|
// Fill in the time entry details
|
||||||
|
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'No Project' }).click();
|
||||||
|
await page.getByText(projectName).click();
|
||||||
|
|
||||||
|
// Set duration
|
||||||
|
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||||
|
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||||
|
|
||||||
|
// Submit the time entry
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||||
|
page.waitForLoadState('networkidle')
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
|
|
||||||
|
// Fill in the time entry details
|
||||||
|
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
|
||||||
|
|
||||||
|
// Add tag
|
||||||
|
await page.getByRole('button', { name: 'Tags' }).click();
|
||||||
|
await page.getByText('Create new tag').click();
|
||||||
|
await page.getByPlaceholder('Tag Name').fill(tagName);
|
||||||
|
await page.getByRole('button', { name: 'Create Tag' }).click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Set duration
|
||||||
|
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||||
|
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||||
|
|
||||||
|
// Submit the time entry
|
||||||
|
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTimeEntryWithBillableStatus(page: Page, isBillable: boolean, duration: string) {
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||||
|
|
||||||
|
// Fill in the time entry details
|
||||||
|
await page.getByTestId('time_entry_description').fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
|
||||||
|
|
||||||
|
// Set billable status
|
||||||
|
await page.getByRole('button', { name: 'Non-Billable' }).click();
|
||||||
|
if (!isBillable) {
|
||||||
|
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
|
||||||
|
} else {
|
||||||
|
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set duration
|
||||||
|
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||||
|
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||||
|
|
||||||
|
// Submit the time entry
|
||||||
|
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that project filtering works in reporting', async ({ page }) => {
|
||||||
|
const project1 = 'Test Project 1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
const project2 = 'Test Project 2 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create time entries for both projects
|
||||||
|
await createTimeEntryWithProject(page, project1, '1h');
|
||||||
|
await createTimeEntryWithProject(page, project2, '2h');
|
||||||
|
|
||||||
|
// Go to reporting and filter by project1
|
||||||
|
await goToReporting(page);
|
||||||
|
await page.getByRole('button', { name: 'Project' }).nth(0).click();
|
||||||
|
await page.getByText(project1).click();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
// escape
|
||||||
|
page.keyboard.press('Escape'),
|
||||||
|
// wait for API request to finish
|
||||||
|
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
|
||||||
|
]);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Verify only project1 time entries are shown
|
||||||
|
await expect(page.getByText(project1)).toBeVisible();
|
||||||
|
await expect(page.getByText(project2)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that tag filtering works in reporting', async ({ page }) => {
|
||||||
|
const tag1 = 'Test Tag 1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
const tag2 = 'Test Tag 2 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create time entries with different tags
|
||||||
|
await createTimeEntryWithTag(page, tag1, '1h');
|
||||||
|
await createTimeEntryWithTag(page, tag2, '2h');
|
||||||
|
|
||||||
|
// Go to reporting and filter by tag1
|
||||||
|
await goToReporting(page);
|
||||||
|
// wait for all requests to finish
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Tags' }).click();
|
||||||
|
await page.getByText(tag1).click();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
// escape
|
||||||
|
page.keyboard.press('Escape'),
|
||||||
|
// wait for API request to finish
|
||||||
|
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify only time entries with tag1 are shown
|
||||||
|
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that billable status filtering works in reporting', async ({ page }) => {
|
||||||
|
// Create billable and non-billable time entries
|
||||||
|
await createTimeEntryWithBillableStatus(page, true, '1h');
|
||||||
|
await createTimeEntryWithBillableStatus(page, false, '2h');
|
||||||
|
|
||||||
|
// Go to reporting and filter by billable
|
||||||
|
await goToReporting(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Billable' }).click();
|
||||||
|
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
// escape
|
||||||
|
page.keyboard.press('Escape'),
|
||||||
|
// wait for API request to finish
|
||||||
|
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
|
||||||
|
]);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('test that detailed view shows time entries correctly', async ({ page }) => {
|
||||||
|
const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create a time entry
|
||||||
|
await createTimeEntryWithProject(page, projectName, '1h');
|
||||||
|
|
||||||
|
// Go to detailed reporting view
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
// Verify the time entry is shown with all details
|
||||||
|
await expect(page.getByText(projectName, { exact: true })).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="Duration"]')).toHaveValue('1h 00min');
|
||||||
|
await expect(page.getByText('Time entry for ' + projectName, { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: test that date range filtering works in reporting
|
||||||
|
|||||||
@@ -218,9 +218,7 @@ test('test that updating a the duration in the overview works on blur', async ({
|
|||||||
const newTimeEntry = timeEntryRows.first();
|
const newTimeEntry = timeEntryRows.first();
|
||||||
await assertThatTimeEntryRowIsStopped(newTimeEntry);
|
await assertThatTimeEntryRowIsStopped(newTimeEntry);
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1500);
|
||||||
const timeEntryDurationInput = newTimeEntry.getByTestId(
|
const timeEntryDurationInput = newTimeEntry.locator('input[name="Duration"]');
|
||||||
'time_entry_duration_input'
|
|
||||||
);
|
|
||||||
await timeEntryDurationInput.fill('20min');
|
await timeEntryDurationInput.fill('20min');
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -238,9 +236,7 @@ test('test that updating a the duration in the overview works on blur', async ({
|
|||||||
timeEntryDurationInput.press('Tab'),
|
timeEntryDurationInput.press('Tab'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(
|
await expect(timeEntryDurationInput).toHaveValue('0h 20min');
|
||||||
newTimeEntry.getByTestId('time_entry_duration_input')
|
|
||||||
).toHaveValue('0h 20min');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test that start stop button stops running timer
|
// Test that start stop button stops running timer
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { formatCents } from '@/packages/ui/src/utils/money';
|
|||||||
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
|
||||||
|
|
||||||
type AggregatedGroupedData = GroupedData & {
|
type AggregatedGroupedData = GroupedData & {
|
||||||
grouped_data?: GroupedData[] | null;
|
grouped_data?: GroupedData[] | null;
|
||||||
@@ -19,6 +18,7 @@ type GroupedData = {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entry: AggregatedGroupedData;
|
entry: AggregatedGroupedData;
|
||||||
indent?: boolean;
|
indent?: boolean;
|
||||||
|
currency: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const expanded = ref(false);
|
const expanded = ref(false);
|
||||||
@@ -48,7 +48,7 @@ const expanded = ref(false);
|
|||||||
{{ formatHumanReadableDuration(entry.seconds) }}
|
{{ formatHumanReadableDuration(entry.seconds) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-end pr-6 flex items-center">
|
<div class="justify-end pr-6 flex items-center">
|
||||||
{{entry.cost ? formatCents(entry.cost, getOrganizationCurrencyString()) : '--' }}
|
{{entry.cost ? formatCents(entry.cost, props.currency) : '--' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -58,6 +58,7 @@ const expanded = ref(false);
|
|||||||
<ReportingRow
|
<ReportingRow
|
||||||
v-for="subEntry in entry.grouped_data"
|
v-for="subEntry in entry.grouped_data"
|
||||||
:key="subEntry.description ?? 'none'"
|
:key="subEntry.description ?? 'none'"
|
||||||
|
:currency="props.currency"
|
||||||
indent
|
indent
|
||||||
:entry="subEntry"></ReportingRow>
|
:entry="subEntry"></ReportingRow>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -446,6 +446,7 @@ const tableData = computed(() => {
|
|||||||
<ReportingRow
|
<ReportingRow
|
||||||
v-for="entry in tableData"
|
v-for="entry in tableData"
|
||||||
:key="entry.description ?? 'none'"
|
:key="entry.description ?? 'none'"
|
||||||
|
:currency="getOrganizationCurrencyString()"
|
||||||
:entry="entry"
|
:entry="entry"
|
||||||
:type="
|
:type="
|
||||||
aggregatedTableTimeEntries.grouped_type
|
aggregatedTableTimeEntries.grouped_type
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ watch(currentPage, () => {
|
|||||||
data-testid="reporting_view"
|
data-testid="reporting_view"
|
||||||
class="overflow-hidden">
|
class="overflow-hidden">
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
|
class="py-3 sm:py-5 min-h-[79px] border-b border-default-background-separator flex justify-between items-center">
|
||||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||||
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
|
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
|
||||||
<ReportingTabNavbar active="shared"></ReportingTabNavbar>
|
<ReportingTabNavbar active="shared"></ReportingTabNavbar>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ChartBarIcon } from '@heroicons/vue/20/solid';
|
|||||||
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
|
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
|
||||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
||||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
|
||||||
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
|
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
@@ -41,6 +40,13 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reportCurrency = computed(() => {
|
||||||
|
if (sharedReportResponseData.value) {
|
||||||
|
return sharedReportResponseData.value?.currency;
|
||||||
|
}
|
||||||
|
return 'EUR';
|
||||||
|
});
|
||||||
|
|
||||||
const aggregatedTableTimeEntries = computed(() => {
|
const aggregatedTableTimeEntries = computed(() => {
|
||||||
if (sharedReportResponseData.value) {
|
if (sharedReportResponseData.value) {
|
||||||
return sharedReportResponseData.value?.data;
|
return sharedReportResponseData.value?.data;
|
||||||
@@ -193,6 +199,7 @@ onMounted(async () => {
|
|||||||
<ReportingRow
|
<ReportingRow
|
||||||
v-for="entry in tableData"
|
v-for="entry in tableData"
|
||||||
:key="entry.description ?? 'none'"
|
:key="entry.description ?? 'none'"
|
||||||
|
:currency="reportCurrency"
|
||||||
:entry="entry"
|
:entry="entry"
|
||||||
:type="
|
:type="
|
||||||
aggregatedTableTimeEntries.grouped_type
|
aggregatedTableTimeEntries.grouped_type
|
||||||
@@ -206,7 +213,7 @@ onMounted(async () => {
|
|||||||
class="justify-end flex items-center font-medium">
|
class="justify-end flex items-center font-medium">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
aggregatedTableTimeEntries.seconds
|
aggregatedTableTimeEntries.seconds,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
@@ -215,7 +222,7 @@ onMounted(async () => {
|
|||||||
{{
|
{{
|
||||||
formatCents(
|
formatCents(
|
||||||
aggregatedTableTimeEntries.cost,
|
aggregatedTableTimeEntries.cost,
|
||||||
getOrganizationCurrencyString()
|
reportCurrency,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -230,7 +230,8 @@ type BillableOption = {
|
|||||||
<div class="space-y-2 mt-1 flex flex-col">
|
<div class="space-y-2 mt-1 flex flex-col">
|
||||||
<DurationHumanInput
|
<DurationHumanInput
|
||||||
v-model:start="localStart"
|
v-model:start="localStart"
|
||||||
v-model:end="localEnd"></DurationHumanInput>
|
v-model:end="localEnd"
|
||||||
|
name="Duration"></DurationHumanInput>
|
||||||
<div class="text-sm flex space-x-1">
|
<div class="text-sm flex space-x-1">
|
||||||
<InformationCircleIcon
|
<InformationCircleIcon
|
||||||
class="w-4 text-text-quaternary"></InformationCircleIcon>
|
class="w-4 text-text-quaternary"></InformationCircleIcon>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ function selectInput(event: Event) {
|
|||||||
<template>
|
<template>
|
||||||
<input
|
<input
|
||||||
v-model="currentTime"
|
v-model="currentTime"
|
||||||
data-testid="time_entry_duration_input"
|
name="Duration"
|
||||||
class="text-text-primary w-[90px] px-2 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
|
class="text-text-primary w-[90px] px-2 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
@focus="selectInput"
|
@focus="selectInput"
|
||||||
@keydown.tab="open = false"
|
@keydown.tab="open = false"
|
||||||
|
|||||||
Reference in New Issue
Block a user