Compare commits

..

4 Commits

Author SHA1 Message Date
Gregor Vostrak
fb41d60a21 fix flakyness in e2e tests for reporting 2025-07-17 18:26:11 +02:00
Gregor Vostrak
6a740015b7 change font to inter, scale down fonts, improve rounding/filter elements 2025-07-17 18:26:11 +02:00
Gregor Vostrak
be873c72fe add rounding frontend to reports, and support for shared reports 2025-07-17 18:26:11 +02:00
Constantin Graf
bbfa411f32 Add rounding feature 2025-07-17 18:26:11 +02:00
13 changed files with 4129 additions and 98 deletions

View File

@@ -86,8 +86,7 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$totalCount = $timeEntriesQuery->count();
@@ -141,15 +140,13 @@ class TimeEntryController extends Controller
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member, bool $canAccessPremiumFeatures): Builder
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
{
$select = TimeEntry::SELECT_COLUMNS;
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
if ($roundingType !== null && $roundingMinutes !== null) {
if ($request->getRoundingType() !== null && $request->getRoundingMinutes() !== null) {
$select = array_diff($select, ['start', 'end']);
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($request->getRoundingType(), $request->getRoundingMinutes()).' as start');
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($request->getRoundingType(), $request->getRoundingMinutes()).' as end');
}
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
@@ -187,19 +184,18 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$debug = $request->getDebug();
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $canAccessPremiumFeatures) {
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$user = $this->user();
$timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$roundingType = $request->getRoundingType();
$roundingMinutes = $request->getRoundingMinutes();
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([
'task',
'client',
@@ -336,15 +332,14 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$roundingType = $request->getRoundingType();
$roundingMinutes = $request->getRoundingMinutes();
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
@@ -385,7 +380,6 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
@@ -397,8 +391,8 @@ class TimeEntryController extends Controller
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$roundingType = $request->getRoundingType();
$roundingMinutes = $request->getRoundingMinutes();
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesAggregateQuery->clone(),

View File

@@ -118,8 +118,7 @@
"extra": {
"laravel": {
"dont-discover": [
"laravel/telescope",
"nwidart/laravel-modules"
"laravel/telescope"
]
}
},

View File

@@ -9,7 +9,6 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider;
use Nwidart\Modules\LaravelModulesServiceProvider;
return [
@@ -198,7 +197,6 @@ return [
App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
LaravelModulesServiceProvider::class,
])->toArray(),
/*

View File

@@ -114,8 +114,8 @@ test('test that project filtering works in reporting', async ({ page }) => {
await page.waitForLoadState('networkidle');
// Verify only project1 time entries are shown
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible();
await expect(page.getByText(project1)).toBeVisible();
await expect(page.getByText(project2)).not.toBeVisible();
});
test('test that tag filtering works in reporting', async ({ page }) => {
@@ -142,7 +142,7 @@ test('test that tag filtering works in reporting', async ({ page }) => {
]);
// Verify only time entries with tag1 are shown
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('1h 00min').first()).toBeVisible();
});
test('test that billable status filtering works in reporting', async ({ page }) => {
@@ -164,7 +164,7 @@ test('test that billable status filtering works in reporting', async ({ page })
]);
await page.waitForLoadState('networkidle');
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('1h 00min').first()).toBeVisible();
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@@ -163,8 +163,10 @@ body {
/* Inter Variable Font with browser compatibility considerations */
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Variable.woff2') format('woff2'),
url('/fonts/Inter-Variable.ttf') format('truetype');
src: url('/fonts/Inter-Variable.woff2') format('woff2 supports variations'),
url('/fonts/Inter-Variable.woff2') format('woff2-variations'),
url('/fonts/Inter-Variable.ttf') format('truetype supports variations'),
url('/fonts/Inter-Variable.ttf') format('truetype-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;

View File

@@ -20,9 +20,6 @@ import {
import { ArrowsUpDownIcon } from '@heroicons/vue/20/solid';
import { computed, ref, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { Link } from '@inertiajs/vue3';
import { CreditCardIcon } from '@heroicons/vue/20/solid';
// TimeEntryRoundingType definition
const TimeEntryRoundingType = {
Up: 'up' as const,
@@ -153,17 +150,7 @@ const iconClass = computed(() => {
</Button>
</PopoverTrigger>
<PopoverContent class="w-72 p-4">
<div v-if="!isAllowedToPerformPremiumAction()" class="flex flex-col space-y-2">
<span class="font-semibold text-xs">Premium</span>
<span class="text-xs text-text-secondary flex-1">Rounding is a premium feature. Upgrade to unlock this feature.</span>
<Link href="/billing">
<Button size="sm" variant="input" class="items-center space-x-1">
<CreditCardIcon class="w-3.5 h-3.5 text-text-tertiary mr-1" />
Go to Billing
</Button>
</Link>
</div>
<div v-else class="space-y-4">
<div class="space-y-4">
<div>
<div class="flex items-center justify-between">
<InputLabel for="enable-rounding" value="Enable Rounding" />

View File

@@ -154,7 +154,7 @@ function onSelectChange(checked: boolean) {
"></BillableToggleButton>
<div class="flex-1">
<button
:class="twMerge('text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary', organization?.time_format === '12-hours' ? 'w-[170px]' : 'w-[120px]')"
:class="twMerge('text-text-secondary w-[110px] px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary', organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[110px]')"
@click="expanded = !expanded">
{{ formatStartEnd(timeEntry.start, timeEntry.end, organization?.time_format) }}
</button>

View File

@@ -21,17 +21,13 @@ abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
protected bool $mockBillingContract = true;
protected function setUp(): void
{
parent::setUp();
Mail::fake();
LogFake::bind();
Http::preventStrayRequests();
if ($this->mockBillingContract) {
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
}
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
// Note: The following line can be used to test timezone edge cases.
// $this->travelTo(Carbon::now()->timezone('Europe/Vienna')->setHour(0)->setMinute(59)->setSecond(0));
}

View File

@@ -409,7 +409,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
'end' => null,
]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
@@ -436,52 +435,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
);
}
public function test_index_endpoint_ignores_rounding_if_organization_has_no_premium_features(): void
{
// Arrange
$this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),
'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'),
]);
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
'end' => null,
]);
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'member_id' => $data->member->getKey(),
'rounding_type' => TimeEntryRoundingType::Up,
'rounding_minutes' => 6,
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('meta')
->where('meta.total', 2)
->count('data', 2)
->where('data.0.id', $timeEntry1->getKey())
->where('data.0.start', '2020-01-01T00:00:08Z')
->where('data.0.end', '2020-01-01T00:00:01Z')
->where('data.1.id', $timeEntry2->getKey())
->where('data.1.start', '2020-01-01T00:00:07Z')
->where('data.1.end', null)
);
}
public function test_index_endpoint_can_round_down(): void
{
// Arrange
@@ -501,7 +454,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
'end' => null,
]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
@@ -547,7 +499,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
'end' => null,
]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act