mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-11 01:32:16 +01:00
Compare commits
10 Commits
feature/ro
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6b45d3e35 | ||
|
|
b11672732b | ||
|
|
97dcadc795 | ||
|
|
e7fa414c06 | ||
|
|
43073b5be2 | ||
|
|
9589c9106d | ||
|
|
8a0d2235a8 | ||
|
|
38f38790d5 | ||
|
|
e3cfc155b8 | ||
|
|
4b726635b2 |
@@ -86,7 +86,8 @@ class TimeEntryController extends Controller
|
|||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||||
|
|
||||||
$totalCount = $timeEntriesQuery->count();
|
$totalCount = $timeEntriesQuery->count();
|
||||||
|
|
||||||
@@ -140,13 +141,15 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* @return Builder<TimeEntry>
|
* @return Builder<TimeEntry>
|
||||||
*/
|
*/
|
||||||
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
|
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member, bool $canAccessPremiumFeatures): Builder
|
||||||
{
|
{
|
||||||
$select = TimeEntry::SELECT_COLUMNS;
|
$select = TimeEntry::SELECT_COLUMNS;
|
||||||
if ($request->getRoundingType() !== null && $request->getRoundingMinutes() !== null) {
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
if ($roundingType !== null && $roundingMinutes !== null) {
|
||||||
$select = array_diff($select, ['start', 'end']);
|
$select = array_diff($select, ['start', 'end']);
|
||||||
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($request->getRoundingType(), $request->getRoundingMinutes()).' as start');
|
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');
|
||||||
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($request->getRoundingType(), $request->getRoundingMinutes()).' as end');
|
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');
|
||||||
}
|
}
|
||||||
$timeEntriesQuery = TimeEntry::query()
|
$timeEntriesQuery = TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
@@ -184,18 +187,19 @@ class TimeEntryController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
$debug = $request->getDebug();
|
$debug = $request->getDebug();
|
||||||
$format = $request->getFormatValue();
|
$format = $request->getFormatValue();
|
||||||
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
if ($format === ExportFormat::PDF && ! $canAccessPremiumFeatures) {
|
||||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||||
}
|
}
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$timezone = $user->timezone;
|
$timezone = $user->timezone;
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
$roundingType = $request->getRoundingType();
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $request->getRoundingMinutes();
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||||
$timeEntriesQuery->with([
|
$timeEntriesQuery->with([
|
||||||
'task',
|
'task',
|
||||||
'client',
|
'client',
|
||||||
@@ -332,14 +336,15 @@ class TimeEntryController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$roundingType = $request->getRoundingType();
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $request->getRoundingMinutes();
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery,
|
$timeEntriesAggregateQuery,
|
||||||
@@ -380,6 +385,7 @@ class TimeEntryController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
$format = $request->getFormatValue();
|
$format = $request->getFormatValue();
|
||||||
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
||||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||||
@@ -391,8 +397,8 @@ class TimeEntryController extends Controller
|
|||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$roundingType = $request->getRoundingType();
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $request->getRoundingMinutes();
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||||
$timeEntriesAggregateQuery->clone(),
|
$timeEntriesAggregateQuery->clone(),
|
||||||
|
|||||||
@@ -118,7 +118,8 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"laravel": {
|
"laravel": {
|
||||||
"dont-discover": [
|
"dont-discover": [
|
||||||
"laravel/telescope"
|
"laravel/telescope",
|
||||||
|
"nwidart/laravel-modules"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Enums\NumberFormat;
|
|||||||
use App\Enums\TimeFormat;
|
use App\Enums\TimeFormat;
|
||||||
use Illuminate\Support\Facades\Facade;
|
use Illuminate\Support\Facades\Facade;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Nwidart\Modules\LaravelModulesServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
@@ -197,6 +198,7 @@ return [
|
|||||||
App\Providers\FortifyServiceProvider::class,
|
App\Providers\FortifyServiceProvider::class,
|
||||||
App\Providers\JetstreamServiceProvider::class,
|
App\Providers\JetstreamServiceProvider::class,
|
||||||
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
|
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
|
||||||
|
LaravelModulesServiceProvider::class,
|
||||||
])->toArray(),
|
])->toArray(),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ test('test that project filtering works in reporting', async ({ page }) => {
|
|||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Verify only project1 time entries are shown
|
// Verify only project1 time entries are shown
|
||||||
await expect(page.getByText(project1)).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
|
||||||
await expect(page.getByText(project2)).not.toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that tag filtering works in reporting', async ({ page }) => {
|
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
|
// Verify only time entries with tag1 are shown
|
||||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that billable status filtering works in reporting', async ({ page }) => {
|
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 page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
await expect(page.getByTestId('reporting_view').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
BIN
public/fonts/InterVariable.ttf
Normal file
BIN
public/fonts/InterVariable.ttf
Normal file
Binary file not shown.
BIN
public/fonts/InterVariable.woff2
Normal file
BIN
public/fonts/InterVariable.woff2
Normal file
Binary file not shown.
@@ -163,10 +163,8 @@ body {
|
|||||||
/* Inter Variable Font with browser compatibility considerations */
|
/* Inter Variable Font with browser compatibility considerations */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
src: url('/fonts/Inter-Variable.woff2') format('woff2 supports variations'),
|
src: url('/fonts/Inter-Variable.woff2') format('woff2'),
|
||||||
url('/fonts/Inter-Variable.woff2') format('woff2-variations'),
|
url('/fonts/Inter-Variable.ttf') format('truetype');
|
||||||
url('/fonts/Inter-Variable.ttf') format('truetype supports variations'),
|
|
||||||
url('/fonts/Inter-Variable.ttf') format('truetype-variations');
|
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import {
|
|||||||
import { ArrowsUpDownIcon } from '@heroicons/vue/20/solid';
|
import { ArrowsUpDownIcon } from '@heroicons/vue/20/solid';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { CreditCardIcon } from '@heroicons/vue/20/solid';
|
||||||
// TimeEntryRoundingType definition
|
// TimeEntryRoundingType definition
|
||||||
const TimeEntryRoundingType = {
|
const TimeEntryRoundingType = {
|
||||||
Up: 'up' as const,
|
Up: 'up' as const,
|
||||||
@@ -150,7 +153,17 @@ const iconClass = computed(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-72 p-4">
|
<PopoverContent class="w-72 p-4">
|
||||||
<div class="space-y-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>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<InputLabel for="enable-rounding" value="Enable Rounding" />
|
<InputLabel for="enable-rounding" value="Enable Rounding" />
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function onSelectChange(checked: boolean) {
|
|||||||
"></BillableToggleButton>
|
"></BillableToggleButton>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<button
|
<button
|
||||||
: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]')"
|
: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]')"
|
||||||
@click="expanded = !expanded">
|
@click="expanded = !expanded">
|
||||||
{{ formatStartEnd(timeEntry.start, timeEntry.end, organization?.time_format) }}
|
{{ formatStartEnd(timeEntry.start, timeEntry.end, organization?.time_format) }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -21,13 +21,17 @@ abstract class TestCase extends BaseTestCase
|
|||||||
{
|
{
|
||||||
use CreatesApplication;
|
use CreatesApplication;
|
||||||
|
|
||||||
|
protected bool $mockBillingContract = true;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
Mail::fake();
|
Mail::fake();
|
||||||
LogFake::bind();
|
LogFake::bind();
|
||||||
Http::preventStrayRequests();
|
Http::preventStrayRequests();
|
||||||
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
|
if ($this->mockBillingContract) {
|
||||||
|
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
|
||||||
|
}
|
||||||
// Note: The following line can be used to test timezone edge cases.
|
// 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));
|
// $this->travelTo(Carbon::now()->timezone('Europe/Vienna')->setHour(0)->setMinute(59)->setSecond(0));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,6 +409,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
|
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
|
||||||
'end' => null,
|
'end' => null,
|
||||||
]);
|
]);
|
||||||
|
$this->actAsOrganizationWithSubscription();
|
||||||
Passport::actingAs($data->user);
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -435,6 +436,52 @@ 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
|
public function test_index_endpoint_can_round_down(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -454,6 +501,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
|
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
|
||||||
'end' => null,
|
'end' => null,
|
||||||
]);
|
]);
|
||||||
|
$this->actAsOrganizationWithSubscription();
|
||||||
Passport::actingAs($data->user);
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -499,6 +547,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
|
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
|
||||||
'end' => null,
|
'end' => null,
|
||||||
]);
|
]);
|
||||||
|
$this->actAsOrganizationWithSubscription();
|
||||||
Passport::actingAs($data->user);
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|||||||
Reference in New Issue
Block a user