Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
048b2e0204 Bump the major-updates group across 1 directory with 12 updates
Bumps the major-updates group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/vue-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/vue-query-devtools) | `5.91.0` | `6.1.10` |
| [lucide-vue-next](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-vue-next) | `0.487.0` | `1.0.0` |
| [tailwind-merge](https://github.com/dcastil/tailwind-merge) | `2.6.1` | `3.5.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.2` | `10.0.1` |
| [@inertiajs/vue3](https://github.com/inertiajs/inertia/tree/HEAD/packages/vue3) | `2.3.13` | `3.0.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.7` | `25.5.0` |
| [laravel-vite-plugin](https://github.com/laravel/vite-plugin) | `2.1.0` | `3.0.0` |
| [postcss-import](https://github.com/postcss/postcss-import) | `15.1.0` | `16.1.1` |
| [postcss-nesting](https://github.com/csstools/postcss-plugins/tree/HEAD/plugins/postcss-nesting) | `12.1.5` | `14.0.0` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `3.4.19` | `4.2.2` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `6.0.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.1` | `8.0.3` |



Updates `@tanstack/vue-query-devtools` from 5.91.0 to 6.1.10
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/vue-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/vue-query-devtools@6.1.10/packages/vue-query-devtools)

Updates `lucide-vue-next` from 0.487.0 to 1.0.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.0.0/packages/lucide-vue-next)

Updates `tailwind-merge` from 2.6.1 to 3.5.0
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/compare/v2.6.1...v3.5.0)

Updates `@eslint/js` from 9.39.2 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@inertiajs/vue3` from 2.3.13 to 3.0.0
- [Release notes](https://github.com/inertiajs/inertia/releases)
- [Changelog](https://github.com/inertiajs/inertia/blob/3.x/CHANGELOG.md)
- [Commits](https://github.com/inertiajs/inertia/commits/v3.0.0/packages/vue3)

Updates `@types/node` from 22.19.7 to 25.5.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `laravel-vite-plugin` from 2.1.0 to 3.0.0
- [Release notes](https://github.com/laravel/vite-plugin/releases)
- [Changelog](https://github.com/laravel/vite-plugin/blob/3.x/CHANGELOG.md)
- [Upgrade guide](https://github.com/laravel/vite-plugin/blob/3.x/UPGRADE.md)
- [Commits](https://github.com/laravel/vite-plugin/compare/v2.1.0...v3.0.0)

Updates `postcss-import` from 15.1.0 to 16.1.1
- [Release notes](https://github.com/postcss/postcss-import/releases)
- [Changelog](https://github.com/postcss/postcss-import/blob/master/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-import/compare/15.1.0...16.1.1)

Updates `postcss-nesting` from 12.1.5 to 14.0.0
- [Changelog](https://github.com/csstools/postcss-plugins/blob/main/plugins/postcss-nesting/CHANGELOG.md)
- [Commits](https://github.com/csstools/postcss-plugins/commits/HEAD/plugins/postcss-nesting)

Updates `tailwindcss` from 3.4.19 to 4.2.2
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/tailwindcss)

Updates `typescript` from 5.9.3 to 6.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

Updates `vite` from 7.3.1 to 8.0.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.3/packages/vite)

---
updated-dependencies:
- dependency-name: "@tanstack/vue-query-devtools"
  dependency-version: 6.1.10
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: lucide-vue-next
  dependency-version: 1.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwind-merge
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@inertiajs/vue3"
  dependency-version: 3.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 25.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: laravel-vite-plugin
  dependency-version: 3.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-import
  dependency-version: 16.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-nesting
  dependency-version: 14.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwindcss
  dependency-version: 4.2.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: vite
  dependency-version: 8.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 08:31:23 +00:00
50 changed files with 1038 additions and 1990 deletions

View File

@@ -1,4 +1,4 @@
# solidtime - The modern Open-Source TimeTracker # solidtime - The modern Open-Source Time Tracker
[![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md) [![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
[![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime) [![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime)

View File

@@ -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', $timeEntry); $this->checkPermission($organization, 'time-entries:update:own');
} else { } else {
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry); $this->checkPermission($organization, 'time-entries:update:all');
} }
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) { if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {

View File

@@ -96,30 +96,6 @@ 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);

View File

@@ -230,37 +230,6 @@ 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
// ============================================= // =============================================

View File

@@ -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('1:00:00'); await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1h 00min');
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 (reporting views promote to HH:MM:SS format) // Verify the new duration is displayed
await expect(durationInput).toHaveValue('2:30:00'); await expect(durationInput).toHaveValue(updatedDuration);
}); });
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -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('1:00:00').first()).toBeVisible(); await expect(page.getByTestId('reporting_view').getByText('1h 00min').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('1:00:00').first()).toBeVisible(); await expect(page.getByTestId('reporting_view').getByText('1h 00min').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('1:00:00').first()).toBeVisible(); await expect(page.getByTestId('reporting_view').getByText('1h 00min').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('1:00:00').first() employee.page.getByTestId('reporting_view').getByText('1h 00min').first()
).toBeVisible(); ).toBeVisible();
}); });

View File

@@ -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('1:00:00').first()).toBeVisible(); await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('3:00:00')).not.toBeVisible(); await expect(page.getByText('3h 00min')).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('1:00:00').first()).toBeVisible(); await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('3:00:00')).not.toBeVisible(); await expect(page.getByText('3h 00min')).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('1:00:00').first()).toBeVisible(); await expect(page.getByTestId('reporting_view').getByText('1h 00min').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('1:00:00').first()).toBeVisible(); await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('3:00:00')).not.toBeVisible(); await expect(page.getByText('3h 00min')).not.toBeVisible();
}); });
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -39,10 +39,6 @@ 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');
} }
@@ -71,14 +67,6 @@ 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,
}) => { }) => {
@@ -345,30 +333,6 @@ 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

2473
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,26 +19,26 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0", "@eslint/js": "^10.0.1",
"@inertiajs/vue3": "^2.0.0", "@inertiajs/vue3": "^3.0.3",
"@playwright/test": "^1.41.1", "@playwright/test": "^1.41.1",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/chroma-js": "^3.1.0", "@types/chroma-js": "^3.1.0",
"@types/node": "^22.10.10", "@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"@vue/tsconfig": "^0.8.0", "@vue/tsconfig": "^0.8.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.6.4", "axios": "^1.6.4",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"laravel-vite-plugin": "^2.1.0", "laravel-vite-plugin": "^3.0.1",
"openapi-zod-client": "^1.16.2", "openapi-zod-client": "^1.16.2",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"postcss-import": "^15.1.0", "postcss-import": "^16.1.1",
"postcss-nesting": "^12.1.5", "postcss-nesting": "^14.0.0",
"tailwindcss": "^3.4.13", "tailwindcss": "^4.2.2",
"typescript": "^5.7.3", "typescript": "^6.0.2",
"vite": "^7.0.0", "vite": "^8.0.8",
"vite-plugin-checker": "^0.12.0", "vite-plugin-checker": "^0.12.0",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-tsc": "^3.0.0" "vue-tsc": "^3.0.0"
@@ -51,7 +51,7 @@
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.3.1", "@tanstack/vue-form": "^1.3.1",
"@tanstack/vue-query": "^5.56.2", "@tanstack/vue-query": "^5.56.2",
"@tanstack/vue-query-devtools": "^5.58.0", "@tanstack/vue-query-devtools": "^6.1.16",
"@tanstack/vue-table": "^8.21.2", "@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0", "@vue/eslint-config-typescript": "^14.3.0",
@@ -64,12 +64,12 @@
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"focus-trap": "^8.0.0", "focus-trap": "^8.0.0",
"lucide-vue-next": "^0.487.0", "lucide-vue-next": "^1.0.0",
"parse-duration": "^2.0.1", "parse-duration": "^2.0.1",
"pinia": "^3.0.0", "pinia": "^3.0.0",
"radix-vue": "^1.9.6", "radix-vue": "^1.9.6",
"reka-ui": "^2.8.2", "reka-ui": "^2.8.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vue-echarts": "^8.0.0", "vue-echarts": "^8.0.0",
"zod": "^3.23.8" "zod": "^3.23.8"

View File

@@ -57,11 +57,11 @@ const showEditModal = ref(false);
</span> </span>
</div> </div>
<div <div
class="whitespace-nowrap flex items-center px-3 py-4 text-sm text-text-primary"> class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
<span> {{ projectCount }} Projects </span> <span class="text-text-secondary"> {{ projectCount }} Projects </span>
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="client.is_archived"> <template v-if="client.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon> <ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span> <span>Archived</span>

View File

@@ -83,28 +83,27 @@ const userHasValidMailAddress = computed(() => {
{{ member.name }} {{ member.name }}
</span> </span>
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ member.email }} {{ member.email }}
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ capitalizeFirstLetter(member.role) }} {{ capitalizeFirstLetter(member.role) }}
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<span v-if="member.billable_rate"> {{
{{ member.billable_rate
formatCents( ? formatCents(
member.billable_rate, member.billable_rate,
organization?.currency, organization?.currency,
organization?.currency_format, organization?.currency_format,
organization?.currency_symbol, organization?.currency_symbol,
organization?.number_format organization?.number_format
) )
}} : '--'
</span> }}
<span v-else class="text-text-tertiary"> -- </span>
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="member.is_placeholder === false"> <template v-if="member.is_placeholder === false">
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon> <CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span> <span>Active</span>

View File

@@ -72,7 +72,7 @@ const billableRateInfo = computed(() => {
return 'Default Rate'; return 'Default Rate';
} }
} }
return null; return '--';
}); });
const showEditProjectModal = ref(false); const showEditProjectModal = ref(false);
@@ -98,13 +98,13 @@ const showEditProjectModal = ref(false);
</span> </span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span> <span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div> </div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary"> <div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden"> <div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
{{ client?.name }} {{ client?.name }}
</div> </div>
<div v-else class="text-text-tertiary">No client</div> <div v-else>No client</div>
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div v-if="project.spent_time"> <div v-if="project.spent_time">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(
@@ -114,24 +114,23 @@ const showEditProjectModal = ref(false);
) )
}} }}
</div> </div>
<div v-else class="text-text-tertiary">--</div> <div v-else>--</div>
</div> </div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary"> <div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge> <UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress <EstimatedTimeProgress
v-else-if="project.estimated_time" v-else-if="project.estimated_time"
:estimated="project.estimated_time" :estimated="project.estimated_time"
:current="project.spent_time"></EstimatedTimeProgress> :current="project.spent_time"></EstimatedTimeProgress>
<span v-else class="text-text-tertiary"> -- </span> <span v-else> -- </span>
</div> </div>
<div <div
v-if="showBillableRate" v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<span v-if="billableRateInfo">{{ billableRateInfo }}</span> {{ billableRateInfo }}
<span v-else class="text-text-tertiary">--</span>
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center font-medium"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_archived"> <template v-if="project.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon> <ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span> <span>Archived</span>

View File

@@ -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, formatReportingDuration, formatWeek } from '@/packages/ui/src/utils/time'; import { formatDate, formatHumanReadableDuration, 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 formatReportingDuration( return formatHumanReadableDuration(
value, value,
organization?.value?.interval_format, organization?.value?.interval_format,
organization?.value?.number_format organization?.value?.number_format

View File

@@ -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 {
formatReportingDuration, formatHumanReadableDuration,
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' : ''">
{{ {{
formatReportingDuration( formatHumanReadableDuration(
aggregatedTableTimeEntries.seconds, aggregatedTableTimeEntries.seconds,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format

View File

@@ -10,7 +10,7 @@ import {
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import { formatReportingDuration } from '@/packages/ui/src/utils/time'; import { formatHumanReadableDuration } 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 formatReportingDuration( return formatHumanReadableDuration(
value, value,
organization?.value?.interval_format, organization?.value?.interval_format,
organization?.value?.number_format organization?.value?.number_format

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatReportingDuration } from '@/packages/ui/src/utils/time'; import { formatHumanReadableDuration } 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' : ''">
{{ {{
formatReportingDuration( formatHumanReadableDuration(
entry.seconds, entry.seconds,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format

View File

@@ -7,8 +7,8 @@ defineProps<{
<template> <template>
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5"> <div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-medium text-sm text-text-secondary">{{ title }}</dt> <dt class="font-semibold text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-xl text-text-primary pt-1 font-medium"> <dd class="text-xl text-text-primary pt-1 font-semibold">
{{ value ?? '--' }} {{ value ?? '--' }}
</dd> </dd>
</div> </div>

View File

@@ -23,7 +23,7 @@ defineProps<{
<div class="items-center justify-center flex-1 hidden @2xs:flex"> <div class="items-center justify-center flex-1 hidden @2xs:flex">
<DayOverviewCardChart :history="history"></DayOverviewCardChart> <DayOverviewCardChart :history="history"></DayOverviewCardChart>
</div> </div>
<div class="flex text-sm items-center justify-center text-text-primary min-w-[65px]"> <div class="flex text-sm items-center justify-center text-text-secondary min-w-[65px]">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(
duration, duration,

View File

@@ -47,9 +47,9 @@ async function startTaskTimer() {
<template> <template>
<div class="px-3.5 py-2 grid grid-cols-5"> <div class="px-3.5 py-2 grid grid-cols-5">
<div class="col-span-4"> <div class="col-span-4">
<p class="text-text-primary text-sm pb-1.5 truncate"> <p class="text-text-secondary text-sm pb-1.5 truncate">
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span> <span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
<span v-else class="text-text-secondary">No description</span> <span v-else>No description</span>
</p> </p>
<ProjectBadge size="base" class="min-w-0 max-w-full" :color="project?.color"> <ProjectBadge size="base" class="min-w-0 max-w-full" :color="project?.color">
<div class="flex items-center lg:space-x-0.5 min-w-0"> <div class="flex items-center lg:space-x-0.5 min-w-0">

View File

@@ -48,7 +48,7 @@ const { data: latestTeamActivity, isLoading } = useQuery({
class="text-center flex flex-1 justify-center items-center"> class="text-center flex flex-1 justify-center items-center">
<div> <div>
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon> <UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
<h3 class="text-text-primary font-medium text-sm">Invite your co-workers</h3> <h3 class="text-text-primary font-semibold text-sm">Invite your co-workers</h3>
<p class="pb-5 text-sm">You can invite your entire team.</p> <p class="pb-5 text-sm">You can invite your entire team.</p>
<SecondaryButton @click="router.visit(route('members'))" <SecondaryButton @click="router.visit(route('members'))"
>Go to Members >Go to Members

View File

@@ -11,7 +11,7 @@ defineProps<{
<div class="col-span-2"> <div class="col-span-2">
<div class="flex justify-between"> <div class="flex justify-between">
<p <p
class="text-sm font-medium min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary"> class="text-xs min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-secondary">
{{ name }} {{ name }}
</p> </p>
<div v-if="working" class="flex space-x-1.5 items-center justify-end"> <div v-if="working" class="flex space-x-1.5 items-center justify-end">
@@ -20,11 +20,11 @@ defineProps<{
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span> class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span> <span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span> </span>
<span class="text-green-500 text-sm block pb-0.5"> working </span> <span class="text-green-500 font-medium text-sm block pb-0.5"> working </span>
</div> </div>
</div> </div>
<div <div
class="text-text-secondary text-sm text-ellipsis whitespace-nowrap max-w-full overflow-hidden"> class="text-text-secondary text-sm font-medium text-ellipsis whitespace-nowrap max-w-full overflow-hidden">
{{ description }} {{ description }}
</div> </div>
</div> </div>

View File

@@ -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 { formatReportingDuration } from '@/packages/ui/src/utils/time'; import { formatHumanReadableDuration } 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 formatReportingDuration( return formatHumanReadableDuration(
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
? formatReportingDuration( ? formatHumanReadableDuration(
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
? formatReportingDuration( ? formatHumanReadableDuration(
totalWeeklyBillableTime, totalWeeklyBillableTime,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format

View File

@@ -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 {
formatReportingDuration, formatHumanReadableDuration,
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' : ''">
{{ {{
formatReportingDuration( formatHumanReadableDuration(
aggregatedTableTimeEntries.seconds, aggregatedTableTimeEntries.seconds,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format

View File

@@ -28,7 +28,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
<CollapsibleRoot v-else v-model:open="open" <CollapsibleRoot v-else v-model:open="open"
><CollapsibleTrigger class="w-full group py-0.5"> ><CollapsibleTrigger class="w-full group py-0.5">
<div <div
class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-regular text-sm items-center justify-between"> class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-medium text-sm items-center justify-between">
<div class="flex items-center gap-x-2"> <div class="flex items-center gap-x-2">
<component <component
:is="icon" :is="icon"

View File

@@ -2,10 +2,8 @@
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>
@@ -17,7 +15,6 @@ 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">
@@ -34,14 +31,6 @@ 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>

View File

@@ -390,7 +390,6 @@ 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"

View File

@@ -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 { formatReportingDuration } 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 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">
{{ {{
formatReportingDuration( formatHumanReadableDuration(
aggregatedTableTimeEntries.seconds, aggregatedTableTimeEntries.seconds,
reportIntervalFormat, reportIntervalFormat,
reportNumberFormat reportNumberFormat

View File

@@ -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 { computed, onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { Field, FieldDescription, FieldLabel } from '@/packages/ui/src/field'; import { Field, 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,12 +52,6 @@ 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);
} }
@@ -155,12 +149,6 @@ 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>

View File

@@ -16,7 +16,6 @@ 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';
@@ -152,7 +151,6 @@ 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>

View File

@@ -1,6 +1,6 @@
{ {
"name": "@solidtime/ui", "name": "@solidtime/ui",
"version": "0.0.21", "version": "0.0.20",
"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",

View File

@@ -32,7 +32,7 @@ const sizeClasses = {
:disabled="loading" :disabled="loading"
:class=" :class="
twMerge( twMerge(
'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-text-primary rounded-lg font-medium inline-flex items-center space-x-1.5 focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus:border-transparent disabled:opacity-25 ease-in-out', 'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-text-primary rounded-lg font-semibold inline-flex items-center space-x-1.5 focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus:border-transparent disabled:opacity-25 ease-in-out',
sizeClasses[props.size], sizeClasses[props.size],
props.class props.class
) )

View File

@@ -22,7 +22,7 @@ const emit = defineEmits<{
</script> </script>
<template> <template>
<div class="flex items-center justify-between bg-default-background px-2 py-1.5"> <div class="flex items-center justify-between bg-background px-2 py-1.5">
<!-- Left: Navigation --> <!-- Left: Navigation -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<Button <Button

View File

@@ -494,7 +494,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div <div
class="fc-header-scroll flex border-b border-border shrink-0 sticky top-0 z-10 bg-default-background"> class="fc-header-scroll flex border-b border-border shrink-0 sticky top-0 z-10 bg-default-background">
<div <div
class="shrink-0 bg-default-background border-r border-border" class="shrink-0 bg-background border-r border-border"
:style="{ :style="{
width: TIME_AXIS_WIDTH + 'px', width: TIME_AXIS_WIDTH + 'px',
minWidth: TIME_AXIS_WIDTH + 'px', minWidth: TIME_AXIS_WIDTH + 'px',
@@ -526,7 +526,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div ref="scrollerRef" class="fc-scroller"> <div ref="scrollerRef" class="fc-scroller">
<div class="flex min-w-0"> <div class="flex min-w-0">
<div <div
class="shrink-0 bg-default-background border-r border-border" class="shrink-0 bg-background border-r border-border"
:style="{ :style="{
width: TIME_AXIS_WIDTH + 'px', width: TIME_AXIS_WIDTH + 'px',
minWidth: TIME_AXIS_WIDTH + 'px', minWidth: TIME_AXIS_WIDTH + 'px',
@@ -553,7 +553,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
class="flex-1 min-w-0 relative" class="flex-1 min-w-0 relative"
@pointerdown="guardedSlotPointerDown($event)"> @pointerdown="guardedSlotPointerDown($event)">
<div <div
class="bg-default-background relative" class="bg-background relative"
:style="{ height: totalGridHeight + 'px' }"> :style="{ height: totalGridHeight + 'px' }">
<div <div
class="absolute inset-0 grid" class="absolute inset-0 grid"

View File

@@ -6,15 +6,10 @@ 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',
} }
); );
@@ -28,7 +23,6 @@ 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',

View File

@@ -35,8 +35,7 @@ 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;
if (aSelected !== bSelected) return aSelected - bSelected; return aSelected - bSelected;
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
}); });
} }
}); });

View File

@@ -32,10 +32,10 @@ const displaysPlaceholder = computed(() => {
<template> <template>
<div class="relative min-w-0 text-ellipsis whitespace-nowrap overflow-hidden"> <div class="relative min-w-0 text-ellipsis whitespace-nowrap overflow-hidden">
<div class="relative text-sm min-w-0"> <div class="relative text-sm font-medium min-w-0">
<div <div
:class="[ :class="[
'opacity-0 h-4 text-sm whitespace-pre min-w-0 pl-1.5 @lg:pl-3 pr-1', 'opacity-0 h-4 text-sm whitespace-pre font-medium min-w-0 pl-1.5 @lg:pl-3 pr-1',
{ 'min-w-[130px]': displaysPlaceholder }, { 'min-w-[130px]': displaysPlaceholder },
]"> ]">
{{ liveDataValue }} {{ liveDataValue }}
@@ -44,7 +44,7 @@ const displaysPlaceholder = computed(() => {
data-testid="time_entry_description" data-testid="time_entry_description"
:value="liveDataValue" :value="liveDataValue"
placeholder="Add a description" placeholder="Add a description"
class="absolute px-0 h-full min-w-0 pl-1.5 @lg:pl-3 pr-1 left-0 top-0 w-full text-sm text-text-primary bg-transparent focus-visible:ring-0 rounded-lg border-0" class="absolute px-0 h-full min-w-0 pl-1.5 @lg:pl-3 pr-1 left-0 top-0 w-full text-sm text-text-primary font-medium bg-transparent focus-visible:ring-0 rounded-lg border-0"
@blur="onChange" @blur="onChange"
@input="onInput" @input="onInput"
@keydown.enter="onChange" /> @keydown.enter="onChange" />

View File

@@ -20,30 +20,24 @@ const selectedTimeEntries = defineModel<TimeEntry[]>('selected', {
default: [], default: [],
}); });
const props = withDefaults( const props = defineProps<{
defineProps<{ timeEntries: TimeEntry[];
timeEntries: TimeEntry[]; projects: Project[];
projects: Project[]; tasks: Task[];
tasks: Task[]; tags: Tag[];
tags: Tag[]; clients: Client[];
clients: Client[]; createTag: (name: string) => Promise<Tag | undefined>;
createTag: (name: string) => Promise<Tag | undefined>; updateTimeEntry: (entry: TimeEntry) => void;
updateTimeEntry: (entry: TimeEntry) => void; updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void; deleteTimeEntries: (entries: TimeEntry[]) => void;
deleteTimeEntries: (entries: TimeEntry[]) => void; createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void; createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>; currency: string;
currency: string; organizationBillableRate: number | null;
organizationBillableRate: number | null; enableEstimatedTime: boolean;
enableEstimatedTime: boolean; canCreateProject: boolean;
canCreateProject: boolean; }>();
groupSimilarTimeEntries?: boolean;
}>(),
{
groupSimilarTimeEntries: true,
}
);
const groupedTimeEntries = computed(() => { const groupedTimeEntries = computed(() => {
const groupedEntriesByDay: Record<string, TimeEntry[]> = {}; const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
@@ -64,11 +58,6 @@ 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) =>

View File

@@ -36,13 +36,15 @@ const organization = inject<ComputedRef<Organization>>('organization');
:class=" :class="
twMerge( twMerge(
'text-text-secondary px-1 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary', 'text-text-secondary px-1 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
showDate ? 'text-xs py-1.5 font-medium' : 'text-sm py-1.5 font-normal', showDate
? 'text-xs py-1.5 font-semibold'
: 'text-sm py-1.5 font-medium',
organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]', organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]',
open && 'border-card-border bg-card-background' open && 'border-card-border bg-card-background'
) )
"> ">
{{ formatStartEnd(start, end, organization?.time_format) }} {{ formatStartEnd(start, end, organization?.time_format) }}
<span v-if="showDate" class="text-text-tertiary font-normal" <span v-if="showDate" class="text-text-tertiary font-medium"
>{{ formatDateLocalized(start, organization?.date_format) }} >{{ formatDateLocalized(start, organization?.date_format) }}
</span> </span>
</button> </button>

View File

@@ -52,7 +52,6 @@ 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: [] }>();
@@ -149,7 +148,8 @@ async function handleDeleteTimeEntry() {
:task="timeEntry.task_id" :task="timeEntry.task_id"
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown> @changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
</div> </div>
<div class="hidden @lg:flex items-center space-x-1 @lg:space-x-2 shrink-0"> <div
class="hidden @lg:flex items-center font-medium space-x-1 @lg:space-x-2 shrink-0">
<div v-if="showMember && members" class="text-sm px-2"> <div v-if="showMember && members" class="text-sm px-2">
{{ memberName }} {{ memberName }}
</div> </div>
@@ -173,7 +173,6 @@ 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)"
@@ -198,7 +197,6 @@ 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 -->

View File

@@ -2,7 +2,6 @@
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';
@@ -19,7 +18,6 @@ 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];
@@ -53,8 +51,7 @@ const currentTime = computed({
if (temporaryCustomTimerEntry.value !== '') { if (temporaryCustomTimerEntry.value !== '') {
return temporaryCustomTimerEntry.value; return temporaryCustomTimerEntry.value;
} }
const formatter = props.isReport ? formatReportingDuration : formatHumanReadableDuration; return 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

View File

@@ -47,14 +47,14 @@ function selectUnselectAll(value: boolean) {
class="group-hover:block hidden" class="group-hover:block hidden"
@update:checked="selectUnselectAll"></Checkbox> @update:checked="selectUnselectAll"></Checkbox>
</div> </div>
<span class="text-text-primary"> <span class="font-medium text-text-secondary">
{{ formatWeekday(date) }} {{ formatWeekday(date) }}
</span> </span>
<span class="text-text-secondary ml-2"> <span class="text-text-tertiary ml-2">
{{ formatDate(date, organization?.date_format) }} {{ formatDate(date, organization?.date_format) }}
</span> </span>
</div> </div>
<div class="text-text-primary pr-2 @lg:pr-[92px]"> <div class="text-text-secondary pr-2 @lg:pr-[92px]">
<span class="font-medium"> <span class="font-medium">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(

View File

@@ -215,7 +215,7 @@ useSelectEvents(
v-model="tempDescription" v-model="tempDescription"
placeholder="What are you working on?" placeholder="What are you working on?"
data-testid="time_entry_description" data-testid="time_entry_description"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary focus:ring-0 transition" class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition"
type="text" type="text"
@keydown.enter="startTimerIfNotActive" @keydown.enter="startTimerIfNotActive"
@keydown.esc="showDropdown = false" @keydown.esc="showDropdown = false"

View File

@@ -118,26 +118,6 @@ 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());

View File

@@ -14,30 +14,28 @@
@tailwind utilities; @tailwind utilities;
:root.dark { :root.dark {
/* Linear/Raycast: cool blue-tinted neutrals, content surface at Material floor */ --color-bg-primary: oklch(0.14 0.0041 285.97);
--color-bg-primary: oklch(0.17 0 0); /* content surface */ --color-bg-secondary: oklch(0.18 0.005 285.97);
--color-bg-secondary: oklch(0.20 0 0); /* cards, input fills */ --color-bg-tertiary: oklch(0.22 0.0112 285.97);
--color-bg-tertiary: oklch(0.25 0 0); /* hover / active row */ --color-bg-quaternary: oklch(0.26 0.015 285.97);
--color-bg-quaternary: oklch(0.29 0 0); /* pressed / selected */ --color-bg-background: oklch(0.1 0 0);
--color-bg-elevated: oklch(0.22 0 0); /* popovers, modals, dropdowns */ --color-text-primary: #ffffff;
--color-bg-background: oklch(0.14 0 0); /* sidebar / chrome — recessed */ --color-text-secondary: #e3e4e6;
--color-text-primary: oklch(0.97 0 0); --color-text-tertiary: #969799;
--color-text-secondary: oklch(0.85 0 0); --color-text-quaternary: #595a5c;
--color-text-tertiary: oklch(0.70 0 0);
--color-text-quaternary: oklch(0.55 0 0);
--color-border-primary: oklch(0.24 0 0); /* separators — above elevated */ --color-border-primary: #191b1f;
--color-border-secondary: oklch(0.28 0 0); /* card borders */ --color-border-secondary: oklch(0.25 0.0098 268.31);
--color-border-tertiary: oklch(0.32 0 0); /* input borders */ --color-border-tertiary: #2c2e33;
--color-border-quaternary: oklch(0.36 0 0); /* emphasized borders */ --color-border-quaternary: #393b42;
--color-input-border-active: rgba(255, 255, 255, 0.18); --color-input-border-active: rgba(255, 255, 255, 0.15);
--theme-color-chart: var(--color-accent-200); --theme-color-chart: var(--color-accent-200);
--theme-color-menu-active: var(--color-bg-secondary); --theme-color-menu-active: var(--color-bg-secondary);
--theme-color-card-background: var(--color-bg-secondary); --theme-color-card-background: var(--color-bg-secondary);
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%); --theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
--theme-shadow-dropdown: 0 8px 24px -4px rgb(0 0 0 / 55%), 0 2px 6px 0 rgb(0 0 0 / 35%); --theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
--theme-color-card-background-active: var(--color-bg-tertiary); --theme-color-card-background-active: var(--color-bg-tertiary);
@@ -189,7 +187,7 @@ body {
--foreground: var(--color-text-primary); --foreground: var(--color-text-primary);
--card: var(--theme-color-card-background); --card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary); --card-foreground: var(--color-text-primary);
--popover: var(--color-bg-elevated, var(--color-bg-secondary)); --popover: var(--color-bg-secondary);
--popover-foreground: var(--color-text-primary); --popover-foreground: var(--color-text-primary);
--primary: var(--color-bg-primary); --primary: var(--color-bg-primary);
--primary-foreground: var(--color-text-primary); --primary-foreground: var(--color-text-primary);

View File

@@ -1,6 +0,0 @@
import { useStorage } from '@vueuse/core';
export const groupSimilarTimeEntriesSetting = useStorage<boolean>(
'group-similar-time-entries',
true
);

View File

@@ -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->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div> style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(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->formatIntervalForReporting(CarbonInterval::seconds($group1Entry['seconds'])) }} {{ $localization->formatInterval(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->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }} {{ $localization->formatInterval(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->formatIntervalForReporting($duration) }} {{ $localization->formatInterval($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->formatIntervalForReporting(CarbonInterval::seconds((int) $value['seconds'])) 'name' => ((int) $value['seconds']) === 0 ? '' : $localization->formatInterval(CarbonInterval::seconds((int) $value['seconds']))
])->toArray()) !!}, ])->toArray()) !!},
itemStyle: { itemStyle: {
borderColor: "#7dd3fc", borderColor: "#7dd3fc",

View File

@@ -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->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div> style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(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->formatIntervalForReporting($timeEntry->getDuration()) }} {{ $localization->formatInterval($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>

View File

@@ -2490,47 +2490,6 @@ 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

View File

@@ -129,58 +129,6 @@ 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