mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 12:42:15 +01:00
Compare commits
8 Commits
1cf33ddb3f
...
fix/dropdo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83da1435be | ||
|
|
b73aa543fd | ||
|
|
2d6f9e514f | ||
|
|
f8e668790b | ||
|
|
77a5e979c6 | ||
|
|
353a579850 | ||
|
|
bd44a2b376 | ||
|
|
277dbaf6eb |
@@ -1,4 +1,4 @@
|
|||||||
# solidtime - The modern Open-Source Time Tracker
|
# solidtime - The modern Open-Source TimeTracker
|
||||||
|
|
||||||
[](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
|
[](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
|
||||||
[](https://codecov.io/gh/solidtime-io/solidtime)
|
[](https://codecov.io/gh/solidtime-io/solidtime)
|
||||||
|
|||||||
@@ -629,9 +629,9 @@ class TimeEntryController extends Controller
|
|||||||
/** @var Member|null $member */
|
/** @var Member|null $member */
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
||||||
$this->checkPermission($organization, 'time-entries:update:own');
|
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:update:all');
|
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
||||||
|
|||||||
@@ -96,6 +96,30 @@ class LocalizationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a duration for reporting contexts (PDF reports, places that display duration
|
||||||
|
* directly next to cost). Promotes the verbose `Hh Mm` format to the compact `HH:MM:SS`
|
||||||
|
* so totals stay narrow and reconcile with cost, which is always computed to the second.
|
||||||
|
*/
|
||||||
|
public function formatIntervalForReporting(CarbonInterval $interval): string
|
||||||
|
{
|
||||||
|
$promoted = [
|
||||||
|
IntervalFormat::HoursMinutes,
|
||||||
|
IntervalFormat::HoursMinutesColonSeparated,
|
||||||
|
];
|
||||||
|
if (! in_array($this->intervalFormat, $promoted, true)) {
|
||||||
|
return $this->formatInterval($interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous = $this->intervalFormat;
|
||||||
|
$this->intervalFormat = IntervalFormat::HoursMinutesSecondsColonSeparated;
|
||||||
|
try {
|
||||||
|
return $this->formatInterval($interval);
|
||||||
|
} finally {
|
||||||
|
$this->intervalFormat = $previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function formatCurrency(Money $money): string
|
public function formatCurrency(Money $money): string
|
||||||
{
|
{
|
||||||
$currencyService = app(CurrencyService::class);
|
$currencyService = app(CurrencyService::class);
|
||||||
|
|||||||
@@ -230,6 +230,37 @@ test('test that theme can be changed to dark and light', async ({ page }) => {
|
|||||||
await expect(page.getByText('System default:')).toBeVisible();
|
await expect(page.getByText('System default:')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Group similar time entries
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that group similar time entries setting can be toggled', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
|
||||||
|
// Get the checkbox
|
||||||
|
const checkbox = page.getByLabel('Group similar time entries');
|
||||||
|
|
||||||
|
// Get initial value and verify it is checked (default is true)
|
||||||
|
const initialValue = await checkbox.isChecked();
|
||||||
|
await expect(checkbox).toBeChecked();
|
||||||
|
|
||||||
|
// Toggle the checkbox
|
||||||
|
await checkbox.click();
|
||||||
|
|
||||||
|
// Reload
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify the value is toggled
|
||||||
|
const afterValue = await page.getByLabel('Group similar time entries').isChecked();
|
||||||
|
expect(afterValue).toBe(!initialValue);
|
||||||
|
|
||||||
|
// Verify localStorage persists the setting
|
||||||
|
const storedValue = await page.evaluate(() =>
|
||||||
|
localStorage.getItem('group-similar-time-entries')
|
||||||
|
);
|
||||||
|
expect(storedValue).toBe(String(!initialValue));
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Two Factor Authentication Tests
|
// Two Factor Authentication Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test('test that detailed view shows time entries correctly', async ({ page, ctx
|
|||||||
|
|
||||||
// Verify the time entry is shown with all details
|
// Verify the time entry is shown with all details
|
||||||
await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();
|
await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();
|
||||||
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1h 00min');
|
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1:00:00');
|
||||||
await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();
|
await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,8 +62,8 @@ test('test that updating duration in detailed view works correctly', async ({ pa
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Verify the new duration is displayed
|
// Verify the new duration is displayed (reporting views promote to HH:MM:SS format)
|
||||||
await expect(durationInput).toHaveValue(updatedDuration);
|
await expect(durationInput).toHaveValue('2:30:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ test('test that task filtering works in reporting', async ({ page, ctx }) => {
|
|||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Verify the report only shows 1h (task1's duration)
|
// Verify the report only shows 1h (task1's duration)
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that task multiselect search filters the option list', async ({ page, ctx }) => {
|
test('test that task multiselect search filters the option list', async ({ page, ctx }) => {
|
||||||
@@ -474,7 +474,7 @@ test('test that tag filtering works in reporting', async ({ page, ctx }) => {
|
|||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Verify only time entries with tag1 are shown
|
// Verify only time entries with tag1 are shown
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that tag dropdown search filters the option list', async ({ page, ctx }) => {
|
test('test that tag dropdown search filters the option list', async ({ page, ctx }) => {
|
||||||
@@ -594,7 +594,7 @@ test('test that billable status filtering works in reporting', async ({ page, ct
|
|||||||
waitForReportingUpdate(page),
|
waitForReportingUpdate(page),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that billable filter can switch between all three states', async ({ page }) => {
|
test('test that billable filter can switch between all three states', async ({ page }) => {
|
||||||
@@ -885,7 +885,7 @@ test.describe('Employee Reporting Restrictions', () => {
|
|||||||
|
|
||||||
// Employee's data should be visible (1h)
|
// Employee's data should be visible (1h)
|
||||||
await expect(
|
await expect(
|
||||||
employee.page.getByTestId('reporting_view').getByText('1h 00min').first()
|
employee.page.getByTestId('reporting_view').getByText('1:00:00').first()
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -292,8 +292,8 @@ test('test that shared report respects task filter', async ({ page, ctx }) => {
|
|||||||
await page.goto(shareableLink);
|
await page.goto(shareableLink);
|
||||||
await expect(page.getByText('Reporting')).toBeVisible();
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
await expect(page.getByText('Total')).toBeVisible();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByText('1:00:00').first()).toBeVisible();
|
||||||
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
await expect(page.getByText('3:00:00')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that shared report respects client filter', async ({ page, ctx }) => {
|
test('test that shared report respects client filter', async ({ page, ctx }) => {
|
||||||
@@ -369,8 +369,8 @@ test('test that shared report respects tag filter', async ({ page, ctx }) => {
|
|||||||
await page.goto(shareableLink);
|
await page.goto(shareableLink);
|
||||||
await expect(page.getByText('Reporting')).toBeVisible();
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
await expect(page.getByText('Total')).toBeVisible();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByText('1:00:00').first()).toBeVisible();
|
||||||
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
await expect(page.getByText('3:00:00')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that shared report respects member filter', async ({ page, ctx }) => {
|
test('test that shared report respects member filter', async ({ page, ctx }) => {
|
||||||
@@ -425,7 +425,7 @@ test('test that shared report with billable filter only shows billable entries',
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Verify only 1h shows before saving
|
// Verify only 1h shows before saving
|
||||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||||
|
|
||||||
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||||
|
|
||||||
@@ -435,8 +435,8 @@ test('test that shared report with billable filter only shows billable entries',
|
|||||||
await expect(page.getByText('Total')).toBeVisible();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
|
|
||||||
// Shared report should only show the 1h billable entry, not the 2h non-billable
|
// Shared report should only show the 1h billable entry, not the 2h non-billable
|
||||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByText('1:00:00').first()).toBeVisible();
|
||||||
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
await expect(page.getByText('3:00:00')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ function getMonthFromTimestamp(timestamp: string): number {
|
|||||||
return new Date(timestamp).getUTCMonth() + 1;
|
return new Date(timestamp).getUTCMonth() + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function goToProfilePage(page: Page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
|
}
|
||||||
|
|
||||||
async function goToTimeOverview(page: Page) {
|
async function goToTimeOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||||
}
|
}
|
||||||
@@ -67,6 +71,14 @@ async function createEmptyTimeEntry(page: Page) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setTimeEntriesGrouping(page: Page, enabled: boolean) {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
const checkbox = page.getByLabel('Group similar time entries');
|
||||||
|
const isChecked = await checkbox.isChecked();
|
||||||
|
if (isChecked !== enabled) await checkbox.click();
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
}
|
||||||
|
|
||||||
test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({
|
test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -333,6 +345,30 @@ test.skip('test that load more works when the end of page is reached', async ({
|
|||||||
await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
|
await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that Group similar time entries option is affected', async ({ page }) => {
|
||||||
|
// Enable grouping
|
||||||
|
await setTimeEntriesGrouping(page, true);
|
||||||
|
|
||||||
|
// Create 2 similar time entries
|
||||||
|
await createEmptyTimeEntry(page);
|
||||||
|
await page.waitForSelector('[data-testid="time_entry_row"]', { timeout: 1000 });
|
||||||
|
await createEmptyTimeEntry(page);
|
||||||
|
|
||||||
|
// Verify similar time entries are grouped
|
||||||
|
await expect(page.getByTestId('grouped_items_count_button').first()).toBeVisible({
|
||||||
|
timeout: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable grouping
|
||||||
|
await setTimeEntriesGrouping(page, false);
|
||||||
|
|
||||||
|
// Verify similar time entries are not grouped
|
||||||
|
await expect(page.locator('[data-testid="time_entry_row"]')).toHaveCount(2, { timeout: 1000 });
|
||||||
|
await expect(page.locator('[data-testid="grouped_items_count_button"]')).toHaveCount(0, {
|
||||||
|
timeout: 1000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: Test that updating the time entry start / end times works while it is running
|
// TODO: Test that updating the time entry start / end times works while it is running
|
||||||
|
|
||||||
// TODO: Test for project update
|
// TODO: Test for project update
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import VChart, { THEME_KEY } from 'vue-echarts';
|
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||||
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
|
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
|
||||||
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
||||||
import { formatDate, formatHumanReadableDuration, formatWeek } from '@/packages/ui/src/utils/time';
|
import { formatDate, formatReportingDuration, formatWeek } from '@/packages/ui/src/utils/time';
|
||||||
import { use } from 'echarts/core';
|
import { use } from 'echarts/core';
|
||||||
import { CanvasRenderer } from 'echarts/renderers';
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
import { BarChart } from 'echarts/charts';
|
import { BarChart } from 'echarts/charts';
|
||||||
@@ -137,7 +137,7 @@ const option = computed(() => ({
|
|||||||
type: 'bar',
|
type: 'bar',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
valueFormatter: (value: number) => {
|
valueFormatter: (value: number) => {
|
||||||
return formatHumanReadableDuration(
|
return formatReportingDuration(
|
||||||
value,
|
value,
|
||||||
organization?.value?.interval_format,
|
organization?.value?.interval_format,
|
||||||
organization?.value?.number_format
|
organization?.value?.number_format
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { SaveIcon } from 'lucide-vue-next';
|
import { SaveIcon } from 'lucide-vue-next';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import {
|
import {
|
||||||
formatHumanReadableDuration,
|
formatReportingDuration,
|
||||||
getDayJsInstance,
|
getDayJsInstance,
|
||||||
getLocalizedDayJs,
|
getLocalizedDayJs,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
@@ -426,7 +426,7 @@ const tableData = computed(() => {
|
|||||||
class="justify-end flex items-center font-medium"
|
class="justify-end flex items-center font-medium"
|
||||||
:class="!showBillableRate ? 'pr-6' : ''">
|
:class="!showBillableRate ? 'pr-6' : ''">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatReportingDuration(
|
||||||
aggregatedTableTimeEntries.seconds,
|
aggregatedTableTimeEntries.seconds,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
TitleComponent,
|
TitleComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
} from 'echarts/components';
|
} from 'echarts/components';
|
||||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
|
||||||
import { useCssVariable } from '@/packages/ui/src';
|
import { useCssVariable } from '@/packages/ui/src';
|
||||||
import type { Organization } from '@/packages/api/src';
|
import type { Organization } from '@/packages/api/src';
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ const option = computed(() => ({
|
|||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
valueFormatter: (value: number) => {
|
valueFormatter: (value: number) => {
|
||||||
return formatHumanReadableDuration(
|
return formatReportingDuration(
|
||||||
value,
|
value,
|
||||||
organization?.value?.interval_format,
|
organization?.value?.interval_format,
|
||||||
organization?.value?.number_format
|
organization?.value?.number_format
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
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, inject, type ComputedRef } from 'vue';
|
import { ref, inject, type ComputedRef } from 'vue';
|
||||||
@@ -44,7 +44,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
|||||||
</div>
|
</div>
|
||||||
<div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''">
|
<div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatReportingDuration(
|
||||||
entry.seconds,
|
entry.seconds,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import CardTitle from '@/packages/ui/src/CardTitle.vue';
|
|||||||
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
||||||
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
|
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
|
||||||
import ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTable.vue';
|
import ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTable.vue';
|
||||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import { getWeekStart } from '@/packages/ui/src/utils/settings';
|
import { getWeekStart } from '@/packages/ui/src/utils/settings';
|
||||||
import { useCssVariable } from '@/packages/ui/src';
|
import { useCssVariable } from '@/packages/ui/src';
|
||||||
@@ -223,7 +223,7 @@ const option = computed(() => {
|
|||||||
type: 'bar',
|
type: 'bar',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
valueFormatter: (value: number) => {
|
valueFormatter: (value: number) => {
|
||||||
return formatHumanReadableDuration(
|
return formatReportingDuration(
|
||||||
value,
|
value,
|
||||||
organization?.value?.interval_format,
|
organization?.value?.interval_format,
|
||||||
organization?.value?.number_format
|
organization?.value?.number_format
|
||||||
@@ -252,7 +252,7 @@ const option = computed(() => {
|
|||||||
title="Spent Time"
|
title="Spent Time"
|
||||||
:value="
|
:value="
|
||||||
totalWeeklyTime
|
totalWeeklyTime
|
||||||
? formatHumanReadableDuration(
|
? formatReportingDuration(
|
||||||
totalWeeklyTime,
|
totalWeeklyTime,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
@@ -263,7 +263,7 @@ const option = computed(() => {
|
|||||||
title="Billable Time"
|
title="Billable Time"
|
||||||
:value="
|
:value="
|
||||||
totalWeeklyBillableTime
|
totalWeeklyBillableTime
|
||||||
? formatHumanReadableDuration(
|
? formatReportingDuration(
|
||||||
totalWeeklyBillableTime,
|
totalWeeklyBillableTime,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||||
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
||||||
import {
|
import {
|
||||||
formatHumanReadableDuration,
|
formatReportingDuration,
|
||||||
getDayJsInstance,
|
getDayJsInstance,
|
||||||
getLocalizedDayJs,
|
getLocalizedDayJs,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
@@ -174,7 +174,7 @@ const showBillableRate = computed(() => {
|
|||||||
class="justify-end flex items-center font-medium"
|
class="justify-end flex items-center font-medium"
|
||||||
:class="!showBillableRate ? 'pr-6' : ''">
|
:class="!showBillableRate ? 'pr-6' : ''">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatReportingDuration(
|
||||||
aggregatedTableTimeEntries.seconds,
|
aggregatedTableTimeEntries.seconds,
|
||||||
organization?.interval_format,
|
organization?.interval_format,
|
||||||
organization?.number_format
|
organization?.number_format
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
import FormSection from '@/Components/FormSection.vue';
|
import FormSection from '@/Components/FormSection.vue';
|
||||||
import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
|
import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
|
||||||
|
import { Checkbox } from '@/packages/ui/src';
|
||||||
import { usePreferredColorScheme } from '@vueuse/core';
|
import { usePreferredColorScheme } from '@vueuse/core';
|
||||||
import { themeSetting } from '@/utils/theme';
|
import { themeSetting } from '@/utils/theme';
|
||||||
|
import { groupSimilarTimeEntriesSetting } from '@/utils/timeEntryGrouping';
|
||||||
|
|
||||||
const preferredColor = usePreferredColorScheme();
|
const preferredColor = usePreferredColorScheme();
|
||||||
</script>
|
</script>
|
||||||
@@ -15,6 +17,7 @@ const preferredColor = usePreferredColorScheme();
|
|||||||
<template #description> Choose how you want solidtime to look on your device </template>
|
<template #description> Choose how you want solidtime to look on your device </template>
|
||||||
|
|
||||||
<template #form>
|
<template #form>
|
||||||
|
<!-- Theme -->
|
||||||
<Field class="col-span-6 sm:col-span-4">
|
<Field class="col-span-6 sm:col-span-4">
|
||||||
<FieldLabel for="theme">Theme</FieldLabel>
|
<FieldLabel for="theme">Theme</FieldLabel>
|
||||||
<Select id="theme" v-model="themeSetting">
|
<Select id="theme" v-model="themeSetting">
|
||||||
@@ -31,6 +34,14 @@ const preferredColor = usePreferredColorScheme();
|
|||||||
System default: {{ preferredColor }}
|
System default: {{ preferredColor }}
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<!-- Group similar time entries -->
|
||||||
|
<Field class="col-span-6 sm:col-span-4" orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
id="group_similar_time_entries"
|
||||||
|
v-model:checked="groupSimilarTimeEntriesSetting" />
|
||||||
|
<FieldLabel for="group_similar_time_entries">Group similar time entries</FieldLabel>
|
||||||
|
</Field>
|
||||||
</template>
|
</template>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
:organization-billable-rate="organization?.billable_rate ?? null"
|
:organization-billable-rate="organization?.billable_rate ?? null"
|
||||||
:duplicate-time-entry="() => createTimeEntry(entry)"
|
:duplicate-time-entry="() => createTimeEntry(entry)"
|
||||||
:members="members"
|
:members="members"
|
||||||
|
is-report
|
||||||
show-date
|
show-date
|
||||||
show-member
|
show-member
|
||||||
:time-entry="entry"
|
:time-entry="entry"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
|||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import { ChartBarIcon } from '@heroicons/vue/20/solid';
|
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 { formatReportingDuration } from '@/packages/ui/src/utils/time';
|
||||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||||
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';
|
||||||
@@ -231,7 +231,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="justify-end flex items-center font-medium">
|
<div class="justify-end flex items-center font-medium">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatReportingDuration(
|
||||||
aggregatedTableTimeEntries.seconds,
|
aggregatedTableTimeEntries.seconds,
|
||||||
reportIntervalFormat,
|
reportIntervalFormat,
|
||||||
reportNumberFormat
|
reportNumberFormat
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FormSection from '@/Components/FormSection.vue';
|
import FormSection from '@/Components/FormSection.vue';
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { Field, FieldLabel } from '@/packages/ui/src/field';
|
import { Field, FieldDescription, FieldLabel } from '@/packages/ui/src/field';
|
||||||
import type { UpdateOrganizationBody } from '@/packages/api/src';
|
import type { UpdateOrganizationBody } from '@/packages/api/src';
|
||||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
@@ -52,6 +52,12 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showsHhMmSsInReports = computed(
|
||||||
|
() =>
|
||||||
|
form.value.interval_format === 'hours-minutes' ||
|
||||||
|
form.value.interval_format === 'hours-minutes-colon-separated'
|
||||||
|
);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
mutation.mutate(form.value);
|
mutation.mutate(form.value);
|
||||||
}
|
}
|
||||||
@@ -149,6 +155,12 @@ async function submit() {
|
|||||||
>
|
>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<FieldDescription v-if="showsHhMmSsInReports">
|
||||||
|
Reports and totals shown next to cost use HH:MM:SS for this format, so the
|
||||||
|
duration reconciles with the billable amount down to the second. Everywhere else
|
||||||
|
(time tracker, calendar, entry rows) seconds are omitted and durations stay in
|
||||||
|
your chosen format.
|
||||||
|
</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useElementVisibility } from '@vueuse/core';
|
|||||||
import { ClockIcon } from '@heroicons/vue/20/solid';
|
import { ClockIcon } from '@heroicons/vue/20/solid';
|
||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
|
import { groupSimilarTimeEntriesSetting } from '@/utils/timeEntryGrouping';
|
||||||
import { useTasksQuery } from '@/utils/useTasksQuery';
|
import { useTasksQuery } from '@/utils/useTasksQuery';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
||||||
@@ -151,6 +152,7 @@ function deleteSelected() {
|
|||||||
:tasks="tasks"
|
:tasks="tasks"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
:time-entries="timeEntries"
|
:time-entries="timeEntries"
|
||||||
|
:group-similar-time-entries="groupSimilarTimeEntriesSetting"
|
||||||
:tags="tags"></TimeEntryGroupedTable>
|
:tags="tags"></TimeEntryGroupedTable>
|
||||||
<div v-if="isPending" class="flex justify-center items-center py-12">
|
<div v-if="isPending" class="flex justify-center items-center py-12">
|
||||||
<LoadingSpinner></LoadingSpinner>
|
<LoadingSpinner></LoadingSpinner>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@solidtime/ui",
|
"name": "@solidtime/ui",
|
||||||
"version": "0.0.20",
|
"version": "0.0.21",
|
||||||
"description": "Package containing the solidtime ui components",
|
"description": "Package containing the solidtime ui components",
|
||||||
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
||||||
"module": "./dist/solidtime-ui-lib.js",
|
"module": "./dist/solidtime-ui-lib.js",
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
size?: string;
|
size?: string;
|
||||||
|
/**
|
||||||
|
* Test ID used for Playwright/E2E tests.
|
||||||
|
*/
|
||||||
|
testId?: string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
expanded: false,
|
expanded: false,
|
||||||
size: 'w-7 h-7',
|
size: 'w-7 h-7',
|
||||||
|
testId: 'grouped_items_count_button',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -23,6 +28,7 @@ const expandedStatusClasses = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
|
:data-testid="props.testId"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
'font-medium text-base rounded flex items-center transition justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent',
|
'font-medium text-base rounded flex items-center transition justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent',
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ watch(open, (isOpen) => {
|
|||||||
sortedItems.value = [...props.items].sort((a, b) => {
|
sortedItems.value = [...props.items].sort((a, b) => {
|
||||||
const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;
|
const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;
|
||||||
const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;
|
const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;
|
||||||
return aSelected - bSelected;
|
if (aSelected !== bSelected) return aSelected - bSelected;
|
||||||
|
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,24 +20,30 @@ const selectedTimeEntries = defineModel<TimeEntry[]>('selected', {
|
|||||||
default: [],
|
default: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
timeEntries: TimeEntry[];
|
defineProps<{
|
||||||
projects: Project[];
|
timeEntries: TimeEntry[];
|
||||||
tasks: Task[];
|
projects: Project[];
|
||||||
tags: Tag[];
|
tasks: Task[];
|
||||||
clients: Client[];
|
tags: Tag[];
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
clients: Client[];
|
||||||
updateTimeEntry: (entry: TimeEntry) => void;
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
|
updateTimeEntry: (entry: TimeEntry) => void;
|
||||||
deleteTimeEntries: (entries: TimeEntry[]) => void;
|
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
|
||||||
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
|
deleteTimeEntries: (entries: TimeEntry[]) => void;
|
||||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
|
||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||||
currency: string;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
organizationBillableRate: number | null;
|
currency: string;
|
||||||
enableEstimatedTime: boolean;
|
organizationBillableRate: number | null;
|
||||||
canCreateProject: boolean;
|
enableEstimatedTime: boolean;
|
||||||
}>();
|
canCreateProject: boolean;
|
||||||
|
groupSimilarTimeEntries?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
groupSimilarTimeEntries: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const groupedTimeEntries = computed(() => {
|
const groupedTimeEntries = computed(() => {
|
||||||
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
|
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
|
||||||
@@ -58,6 +64,11 @@ const groupedTimeEntries = computed(() => {
|
|||||||
const newDailyEntries: TimeEntriesGroupedByType[] = [];
|
const newDailyEntries: TimeEntriesGroupedByType[] = [];
|
||||||
|
|
||||||
for (const entry of dailyEntries) {
|
for (const entry of dailyEntries) {
|
||||||
|
if (!props.groupSimilarTimeEntries) {
|
||||||
|
newDailyEntries.push({ ...entry, timeEntries: [entry] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// check if same entry already exists
|
// check if same entry already exists
|
||||||
const oldEntriesIndex = newDailyEntries.findIndex(
|
const oldEntriesIndex = newDailyEntries.findIndex(
|
||||||
(e) =>
|
(e) =>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const props = defineProps<{
|
|||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
canCreateProject: boolean;
|
canCreateProject: boolean;
|
||||||
enableEstimatedTime: boolean;
|
enableEstimatedTime: boolean;
|
||||||
|
isReport?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ selected: []; unselected: [] }>();
|
const emit = defineEmits<{ selected: []; unselected: [] }>();
|
||||||
@@ -172,6 +173,7 @@ async function handleDeleteTimeEntry() {
|
|||||||
<TimeEntryRowDurationInput
|
<TimeEntryRowDurationInput
|
||||||
:start="timeEntry.start"
|
:start="timeEntry.start"
|
||||||
:end="timeEntry.end"
|
:end="timeEntry.end"
|
||||||
|
:is-report="props.isReport"
|
||||||
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
|
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
|
||||||
<TimeTrackerStartStop
|
<TimeTrackerStartStop
|
||||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||||
@@ -196,6 +198,7 @@ async function handleDeleteTimeEntry() {
|
|||||||
<TimeEntryRowDurationInput
|
<TimeEntryRowDurationInput
|
||||||
:start="timeEntry.start"
|
:start="timeEntry.start"
|
||||||
:end="timeEntry.end"
|
:end="timeEntry.end"
|
||||||
|
:is-report="props.isReport"
|
||||||
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
|
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
|
||||||
</div>
|
</div>
|
||||||
<!-- Second row: project/task - tags - billable - start - more -->
|
<!-- Second row: project/task - tags - billable - start - more -->
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
calculateDifference,
|
calculateDifference,
|
||||||
formatHumanReadableDuration,
|
formatHumanReadableDuration,
|
||||||
|
formatReportingDuration,
|
||||||
parseTimeInput,
|
parseTimeInput,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { computed, ref, inject, type ComputedRef } from 'vue';
|
import { computed, ref, inject, type ComputedRef } from 'vue';
|
||||||
@@ -18,6 +19,7 @@ const organizationSettings = computed(() => ({
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
start: string;
|
start: string;
|
||||||
end: string | null;
|
end: string | null;
|
||||||
|
isReport?: boolean;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
changed: [start: string, end: string | null];
|
changed: [start: string, end: string | null];
|
||||||
@@ -51,7 +53,8 @@ const currentTime = computed({
|
|||||||
if (temporaryCustomTimerEntry.value !== '') {
|
if (temporaryCustomTimerEntry.value !== '') {
|
||||||
return temporaryCustomTimerEntry.value;
|
return temporaryCustomTimerEntry.value;
|
||||||
}
|
}
|
||||||
return formatHumanReadableDuration(
|
const formatter = props.isReport ? formatReportingDuration : formatHumanReadableDuration;
|
||||||
|
return formatter(
|
||||||
calculateDifference(props.start, props.end),
|
calculateDifference(props.start, props.end),
|
||||||
organizationSettings.value.intervalFormat,
|
organizationSettings.value.intervalFormat,
|
||||||
organizationSettings.value.numberFormat
|
organizationSettings.value.numberFormat
|
||||||
|
|||||||
@@ -118,6 +118,26 @@ export function formatHumanReadableDuration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a duration for reporting views where cost and duration must reconcile.
|
||||||
|
*
|
||||||
|
* When the org's `hours-minutes` format is selected, seconds are normally dropped for
|
||||||
|
* readability (e.g. "14h 45min"). In reports this can make the total duration appear
|
||||||
|
* inconsistent with the billable cost (which is computed to the second). To keep the
|
||||||
|
* two columns reconcilable without inflating column widths with "14h 45min 06s",
|
||||||
|
* promote to the compact `HH:MM:SS` format in reporting contexts.
|
||||||
|
*/
|
||||||
|
export function formatReportingDuration(
|
||||||
|
duration: number,
|
||||||
|
intervalFormat?: string,
|
||||||
|
numberFormat?: string
|
||||||
|
): string {
|
||||||
|
const promoted =
|
||||||
|
intervalFormat === 'hours-minutes' || intervalFormat === 'hours-minutes-colon-separated';
|
||||||
|
const effectiveFormat = promoted ? 'hours-minutes-seconds-colon-separated' : intervalFormat;
|
||||||
|
return formatHumanReadableDuration(duration, effectiveFormat, numberFormat);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDuration(duration: number): string {
|
export function formatDuration(duration: number): string {
|
||||||
const dayJsDuration = dayjs.duration(duration, 's');
|
const dayJsDuration = dayjs.duration(duration, 's');
|
||||||
const hours = Math.floor(dayJsDuration.asHours());
|
const hours = Math.floor(dayJsDuration.asHours());
|
||||||
|
|||||||
6
resources/js/utils/timeEntryGrouping.ts
Normal file
6
resources/js/utils/timeEntryGrouping.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
|
||||||
|
export const groupSimilarTimeEntriesSetting = useStorage<boolean>(
|
||||||
|
'group-similar-time-entries',
|
||||||
|
true
|
||||||
|
);
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
<div style="padding: 8px 12px; border-radius: 8px;">
|
<div style="padding: 8px 12px; border-radius: 8px;">
|
||||||
<div style="color: #71717a; font-weight: 600;">Duration</div>
|
<div style="color: #71717a; font-weight: 600;">Duration</div>
|
||||||
<div
|
<div
|
||||||
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
|
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
|
||||||
</div>
|
</div>
|
||||||
@if($showBillableRate)
|
@if($showBillableRate)
|
||||||
<div style="padding: 8px 12px; border-radius: 8px;">
|
<div style="padding: 8px 12px; border-radius: 8px;">
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: left;">
|
<td style="text-align: left;">
|
||||||
{{ $localization->formatInterval(CarbonInterval::seconds($group1Entry['seconds'])) }}
|
{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($group1Entry['seconds'])) }}
|
||||||
</td>
|
</td>
|
||||||
@if($showBillableRate)
|
@if($showBillableRate)
|
||||||
<td style="text-align: right;">
|
<td style="text-align: right;">
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
Total
|
Total
|
||||||
</td>
|
</td>
|
||||||
<td style="font-weight: 500;color: #18181b;">
|
<td style="font-weight: 500;color: #18181b;">
|
||||||
{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }}
|
{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }}
|
||||||
</td>
|
</td>
|
||||||
@if($showBillableRate)
|
@if($showBillableRate)
|
||||||
<td style="text-align: right; font-weight: 500;color: #18181b;">
|
<td style="text-align: right; font-weight: 500;color: #18181b;">
|
||||||
@@ -282,7 +282,7 @@
|
|||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ $localization->formatInterval($duration) }}
|
{{ $localization->formatIntervalForReporting($duration) }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ $localization->formatNumber($duration->totalHours) }}
|
{{ $localization->formatNumber($duration->totalHours) }}
|
||||||
@@ -403,7 +403,7 @@
|
|||||||
type: "bar",
|
type: "bar",
|
||||||
data: {!! json_encode(collect($dataHistoryChart['grouped_data'])->map(fn($value) => (object) [
|
data: {!! json_encode(collect($dataHistoryChart['grouped_data'])->map(fn($value) => (object) [
|
||||||
'value' => $value['seconds'],
|
'value' => $value['seconds'],
|
||||||
'name' => ((int) $value['seconds']) === 0 ? '' : $localization->formatInterval(CarbonInterval::seconds((int) $value['seconds']))
|
'name' => ((int) $value['seconds']) === 0 ? '' : $localization->formatIntervalForReporting(CarbonInterval::seconds((int) $value['seconds']))
|
||||||
])->toArray()) !!},
|
])->toArray()) !!},
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
borderColor: "#7dd3fc",
|
borderColor: "#7dd3fc",
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
<div style="padding: 8px 12px; border-radius: 8px;">
|
<div style="padding: 8px 12px; border-radius: 8px;">
|
||||||
<div style="color: #71717a; font-weight: 600;">Duration</div>
|
<div style="color: #71717a; font-weight: 600;">Duration</div>
|
||||||
<div
|
<div
|
||||||
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
|
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
|
||||||
</div>
|
</div>
|
||||||
@if($showBillableRate)
|
@if($showBillableRate)
|
||||||
<div style="padding: 8px 12px; border-radius: 8px;">
|
<div style="padding: 8px 12px; border-radius: 8px;">
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
{{ $localization->formatTime($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatTime($timeEntry->end->timezone($timezone)) }}
|
{{ $localization->formatTime($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatTime($timeEntry->end->timezone($timezone)) }}
|
||||||
</td>
|
</td>
|
||||||
<td style="overflow-wrap: break-word; min-width: 75px;">
|
<td style="overflow-wrap: break-word; min-width: 75px;">
|
||||||
{{ $localization->formatInterval($timeEntry->getDuration()) }}
|
{{ $localization->formatIntervalForReporting($timeEntry->getDuration()) }}
|
||||||
</td>
|
</td>
|
||||||
<td style="overflow-wrap: break-word;">{{ $timeEntry->billable ? 'Yes' : 'No' }}</td>
|
<td style="overflow-wrap: break-word;">{{ $timeEntry->billable ? 'Yes' : 'No' }}</td>
|
||||||
<td style="overflow-wrap: break-word; min-width: 75px;">{{ count($timeEntry->tagsRelation) === 0 ? '-' : $timeEntry->tagsRelation->implode('name', ', ') }}</td>
|
<td style="overflow-wrap: break-word; min-width: 75px;">{{ count($timeEntry->tagsRelation) === 0 ? '-' : $timeEntry->tagsRelation->implode('name', ', ') }}</td>
|
||||||
|
|||||||
@@ -2490,6 +2490,47 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertForbidden();
|
$response->assertForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_update_endpoint_fails_if_time_entry_belongs_to_different_organization_than_url_even_with_update_all_permission(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// Attacker: has `time-entries:update:all` in their own organization (orgA).
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:all',
|
||||||
|
'projects:view:all',
|
||||||
|
]);
|
||||||
|
$attackerProject = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
|
||||||
|
// Victim: entirely separate organization (orgB). Attacker has NO membership in orgB.
|
||||||
|
$victimOrgData = $this->createUserWithPermission([], true);
|
||||||
|
$victimTimeEntry = TimeEntry::factory()
|
||||||
|
->forOrganization($victimOrgData->organization)
|
||||||
|
->forMember($victimOrgData->ownerMember)
|
||||||
|
->create([
|
||||||
|
'description' => 'victim-original',
|
||||||
|
'project_id' => null,
|
||||||
|
'task_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act: PUT to /organizations/{orgA}/time-entries/{victim_uuid} — URL org is attacker's
|
||||||
|
// own org, but the route-bound time entry belongs to orgB.
|
||||||
|
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $victimTimeEntry->getKey()]), [
|
||||||
|
'description' => 'attacker-overwrite',
|
||||||
|
'project_id' => $attackerProject->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert: must be rejected. Before the fix this returned 200 and rewrote the
|
||||||
|
// victim row with attacker's project_id while keeping organization_id = orgB.
|
||||||
|
$response->assertForbidden();
|
||||||
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
|
'id' => $victimTimeEntry->getKey(),
|
||||||
|
'organization_id' => $victimOrgData->organization->getKey(),
|
||||||
|
'description' => 'victim-original',
|
||||||
|
'project_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_time_entries_for_other_users_in_organization(): void
|
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_time_entries_for_other_users_in_organization(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -129,6 +129,58 @@ class LocalizationServiceTest extends TestCaseWithDatabase
|
|||||||
$this->assertSame('30001:03:04', $formatted);
|
$this->assertSame('30001:03:04', $formatted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_format_interval_for_reporting_with_type_decimal(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
|
||||||
|
$this->localizationService->setIntervalFormat(IntervalFormat::Decimal);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$formatted = $this->localizationService->formatIntervalForReporting($interval);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('30.001,05 h', $formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_format_interval_for_reporting_with_type_hours_minutes(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
|
||||||
|
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutes);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$formatted = $this->localizationService->formatIntervalForReporting($interval);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('30001:03:04', $formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_format_interval_for_reporting_with_type_hours_minutes_colon_separated(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
|
||||||
|
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesColonSeparated);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$formatted = $this->localizationService->formatIntervalForReporting($interval);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('30001:03:04', $formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_format_interval_for_reporting_with_type_hours_minutes_seconds_colon_separated(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
|
||||||
|
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesSecondsColonSeparated);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$formatted = $this->localizationService->formatIntervalForReporting($interval);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('30001:03:04', $formatted);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_format_currency_with_type_symbol_after_with_space_and_number_format_thousands_space_decimal_comma(): void
|
public function test_format_currency_with_type_symbol_after_with_space_and_number_format_thousands_space_decimal_comma(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
Reference in New Issue
Block a user