mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
5 Commits
feature/fi
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6b45d3e35 | ||
|
|
b11672732b | ||
|
|
97dcadc795 | ||
|
|
e7fa414c06 | ||
|
|
43073b5be2 |
@@ -86,7 +86,8 @@ class TimeEntryController extends Controller
|
||||
$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();
|
||||
|
||||
@@ -140,13 +141,15 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* @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;
|
||||
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[] = 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');
|
||||
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');
|
||||
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');
|
||||
}
|
||||
$timeEntriesQuery = TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
@@ -184,18 +187,19 @@ 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 && ! $this->canAccessPremiumFeatures($organization)) {
|
||||
if ($format === ExportFormat::PDF && ! $canAccessPremiumFeatures) {
|
||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||
}
|
||||
$user = $this->user();
|
||||
$timezone = $user->timezone;
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
$roundingType = $request->getRoundingType();
|
||||
$roundingMinutes = $request->getRoundingMinutes();
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||
$timeEntriesQuery->with([
|
||||
'task',
|
||||
'client',
|
||||
@@ -332,14 +336,15 @@ 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 = $request->getRoundingType();
|
||||
$roundingMinutes = $request->getRoundingMinutes();
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery,
|
||||
@@ -380,6 +385,7 @@ 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;
|
||||
@@ -391,8 +397,8 @@ class TimeEntryController extends Controller
|
||||
$group = $request->getGroup();
|
||||
$subGroup = $request->getSubGroup();
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$roundingType = $request->getRoundingType();
|
||||
$roundingMinutes = $request->getRoundingMinutes();
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||
$timeEntriesAggregateQuery->clone(),
|
||||
|
||||
@@ -118,7 +118,8 @@
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": [
|
||||
"laravel/telescope"
|
||||
"laravel/telescope",
|
||||
"nwidart/laravel-modules"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Nwidart\Modules\LaravelModulesServiceProvider;
|
||||
|
||||
return [
|
||||
|
||||
@@ -197,6 +198,7 @@ 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(),
|
||||
|
||||
/*
|
||||
|
||||
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 */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
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');
|
||||
src: url('/fonts/Inter-Variable.woff2') format('woff2'),
|
||||
url('/fonts/Inter-Variable.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
|
||||
@@ -20,6 +20,9 @@ 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,
|
||||
@@ -150,7 +153,17 @@ const iconClass = computed(() => {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<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 class="flex items-center justify-between">
|
||||
<InputLabel for="enable-rounding" value="Enable Rounding" />
|
||||
|
||||
@@ -154,7 +154,7 @@ function onSelectChange(checked: boolean) {
|
||||
"></BillableToggleButton>
|
||||
<div class="flex-1">
|
||||
<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">
|
||||
{{ formatStartEnd(timeEntry.start, timeEntry.end, organization?.time_format) }}
|
||||
</button>
|
||||
|
||||
@@ -21,13 +21,17 @@ abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
use CreatesApplication;
|
||||
|
||||
protected bool $mockBillingContract = true;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Mail::fake();
|
||||
LogFake::bind();
|
||||
Http::preventStrayRequests();
|
||||
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
|
||||
if ($this->mockBillingContract) {
|
||||
$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));
|
||||
}
|
||||
|
||||
@@ -409,6 +409,7 @@ 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
|
||||
@@ -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
|
||||
{
|
||||
// Arrange
|
||||
@@ -454,6 +501,7 @@ 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
|
||||
@@ -499,6 +547,7 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user