Compare commits

...

5 Commits

9 changed files with 365 additions and 13 deletions

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TimeEntryResource\Pages;
use App\Models\Member;
use App\Models\TimeEntry;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
@@ -16,6 +17,7 @@ use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class TimeEntryResource extends Resource
{
@@ -51,6 +53,20 @@ class TimeEntryResource extends Resource
->rules([
'after_or_equal:start',
]),
Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->required(),
Select::make('member_id')
->relationship(
name: 'member',
titleAttribute: 'id',
modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization'])
)
->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')')
->searchable()
->preload()
->required(),
Select::make('user_id')
->relationship(name: 'user', titleAttribute: 'email')
->searchable(['name', 'email'])
@@ -59,7 +75,10 @@ class TimeEntryResource extends Resource
->relationship(name: 'project', titleAttribute: 'name')
->searchable(['name'])
->nullable(),
// TODO
Select::make('task_id')
->relationship(name: 'task', titleAttribute: 'name')
->searchable(['name'])
->nullable(),
]);
}

View File

@@ -5,9 +5,28 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Resources\Pages\CreateRecord;
class CreateTimeEntry extends CreateRecord
{
protected static string $resource = TimeEntryResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
if (isset($data['member_id'])) {
/** @var Member|null $member */
$member = Member::query()->find($data['member_id']);
if ($member !== null) {
$data['user_id'] = $member->user_id;
$data['organization_id'] = $member->organization_id;
}
}
return $data;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
@@ -19,4 +20,22 @@ class EditTimeEntry extends EditRecord
->icon('heroicon-m-trash'),
];
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeSave(array $data): array
{
if (isset($data['member_id'])) {
/** @var Member|null $member */
$member = Member::query()->find($data['member_id']);
if ($member !== null) {
$data['user_id'] = $member->user_id;
$data['organization_id'] = $member->organization_id;
}
}
return $data;
}
}

View File

@@ -31,12 +31,17 @@ class TimeEntryService
throw new LogicException('Rounding minutes must be greater than 0');
}
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
} elseif ($roundingType === TimeEntryRoundingType::Up) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
// If end is already on a boundary, keep it; otherwise round up to next boundary
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
'THEN '.$end.' '.
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
'END';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
}
}
}

View File

@@ -6,10 +6,10 @@ import type { Dayjs } from 'dayjs';
const props = defineProps<{
date: Dayjs;
totalMinutes?: number;
totalSeconds?: number;
}>();
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
// Injected organization for formatting settings
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
@@ -25,7 +25,7 @@ const dateFormat = computed(() => organization?.value?.date_format);
</div>
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
<span class="block text-xs text-muted-foreground font-medium mt-1">
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
</span>
</div>
</template>

View File

@@ -179,20 +179,20 @@ const dailyTotals = computed(() => {
const totals: Record<string, number> = {};
props.timeEntries.forEach((entry) => {
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
let duration: number;
let durationSeconds: number;
if (entry.end !== null) {
// Completed entry
duration = getDayJsInstance()(entry.end).diff(
durationSeconds = getDayJsInstance()(entry.end).diff(
getDayJsInstance()(entry.start),
'minutes'
'seconds'
);
} else {
// Running entry - use current time
duration = currentTime.value.diff(getDayJsInstance()(entry.start), 'minutes');
durationSeconds = currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds');
}
totals[date] = (totals[date] || 0) + duration;
totals[date] = (totals[date] || 0) + durationSeconds;
});
return totals;
});
@@ -444,7 +444,7 @@ onUnmounted(() => {
:date="
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
"
:total-minutes="
:total-seconds="
dailyTotals[
getDayJsInstance()(arg.date)
.utc()

View File

@@ -436,6 +436,52 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
);
}
public function test_index_endpoint_can_round_up_but_does_not_round_up_if_already_on_border(): 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:06:00'),
]);
$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->actAsOrganizationWithSubscription();
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:00Z')
->where('data.0.end', '2020-01-01T00:06:00Z')
->where('data.1.id', $timeEntry2->getKey())
->where('data.1.start', '2020-01-01T00:00:00Z')
->where('data.1.end', '2020-01-01T00:18:00Z')
);
}
public function test_index_endpoint_ignores_rounding_if_organization_has_no_premium_features(): void
{
// Arrange

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Tests\Unit\Filament\Resources;
use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use App\Models\Organization;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Facades\Config;
@@ -50,4 +52,149 @@ class TimeEntryResourceTest extends FilamentTestCase
// Assert
$response->assertSuccessful();
}
public function test_can_see_create_page_of_time_entry(): void
{
// Act
$response = Livewire::test(TimeEntryResource\Pages\CreateTimeEntry::class);
// Assert
$response->assertSuccessful();
}
public function test_can_create_time_entry(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()
->forOrganization($organization)
->forUser($user)
->create();
// Act
$response = Livewire::test(TimeEntryResource\Pages\CreateTimeEntry::class)
->fillForm([
'description' => 'Test time entry',
'billable' => true,
'start' => '2024-01-01 08:00:00',
'end' => '2024-01-01 10:00:00',
'member_id' => $member->getKey(),
])
->call('create')
->assertHasNoFormErrors();
// Assert
$response->assertSuccessful();
$timeEntry = TimeEntry::where('description', 'Test time entry')->first();
$this->assertNotNull($timeEntry);
$this->assertSame($member->getKey(), $timeEntry->member_id);
$this->assertSame($user->getKey(), $timeEntry->user_id);
$this->assertSame($organization->getKey(), $timeEntry->organization_id);
$this->assertTrue($timeEntry->billable);
}
public function test_can_create_time_entry_and_derives_user_and_organization_from_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()
->forOrganization($organization)
->forUser($user)
->create();
$otherUser = User::factory()->create();
$otherOrganization = Organization::factory()->create();
// Act
$response = Livewire::test(TimeEntryResource\Pages\CreateTimeEntry::class)
->fillForm([
'description' => 'Derived fields test',
'billable' => false,
'start' => '2024-03-01 09:00:00',
'end' => '2024-03-01 11:00:00',
'member_id' => $member->getKey(),
'user_id' => $otherUser->getKey(),
'organization_id' => $otherOrganization->getKey(),
])
->call('create')
->assertHasNoFormErrors();
// Assert
$response->assertSuccessful();
$timeEntry = TimeEntry::where('description', 'Derived fields test')->first();
$this->assertNotNull($timeEntry);
$this->assertSame($user->getKey(), $timeEntry->user_id);
$this->assertSame($organization->getKey(), $timeEntry->organization_id);
}
public function test_can_update_time_entry(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()
->forOrganization($organization)
->forUser($user)
->create();
$timeEntry = TimeEntry::factory()->forMember($member)->create();
// Act
$response = Livewire::test(TimeEntryResource\Pages\EditTimeEntry::class, ['record' => $timeEntry->getKey()])
->fillForm([
'description' => 'Updated description',
'billable' => true,
'start' => '2024-02-01 08:00:00',
'end' => '2024-02-01 12:00:00',
'member_id' => $member->getKey(),
])
->call('save')
->assertHasNoFormErrors();
// Assert
$response->assertSuccessful();
$timeEntry->refresh();
$this->assertSame('Updated description', $timeEntry->description);
$this->assertTrue($timeEntry->billable);
$this->assertSame($user->getKey(), $timeEntry->user_id);
$this->assertSame($organization->getKey(), $timeEntry->organization_id);
}
public function test_update_time_entry_derives_user_and_organization_from_new_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()
->forOrganization($organization)
->forUser($user)
->create();
$timeEntry = TimeEntry::factory()->create();
$newOrganization = Organization::factory()->create();
$newUser = User::factory()->create();
$newMember = Member::factory()
->forOrganization($newOrganization)
->forUser($newUser)
->create();
// Act
$response = Livewire::test(TimeEntryResource\Pages\EditTimeEntry::class, ['record' => $timeEntry->getKey()])
->fillForm([
'description' => 'Reassigned entry',
'billable' => false,
'start' => '2024-02-01 08:00:00',
'end' => '2024-02-01 12:00:00',
'member_id' => $newMember->getKey(),
])
->call('save')
->assertHasNoFormErrors();
// Assert
$response->assertSuccessful();
$timeEntry->refresh();
$this->assertSame($newMember->getKey(), $timeEntry->member_id);
$this->assertSame($newUser->getKey(), $timeEntry->user_id);
$this->assertSame($newOrganization->getKey(), $timeEntry->organization_id);
}
}

View File

@@ -1205,4 +1205,101 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
];
$this->assertEqualsCanonicalizing($expected, $result);
}
/**
* Test that rounding up does NOT add extra time when the entry is already on a 15-minute boundary.
* f.e. 13:00 - 14:30 (90 minutes) should stay at 90 minutes when rounding up with 15-minute interval.
*/
public function test_aggregate_time_round_up_does_not_add_time_when_already_on_boundary(): void
{
// Arrange
// Create a time entry with duration exactly on a 15-minute boundary (90 minutes = 5400 seconds)
// This simulates 13:00 - 14:30 (or any 90-minute entry)
$project = Project::factory()->create();
TimeEntry::factory()->startWithDuration(
Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 13:00:00'),
5400 // 90 minutes = 1 hour 30 minutes, exactly on 15-minute boundary
)->forProject($project)->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
null,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
TimeEntryRoundingType::Up,
15
);
// Assert
// The entry is already on a 15-minute boundary (90 minutes), so it should stay at 90 minutes (5400 seconds)
$this->assertEqualsCanonicalizing([
'seconds' => 5400, // 90 minutes - should NOT be rounded to 105 minutes (6300 seconds)
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 5400, // 90 minutes
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
], $result);
}
/**
* Test that rounding up works correctly for entries NOT on a boundary.
* Example: 13:00 - 13:48 (48 minutes) should round up to 13:00 - 14:00 (60 minutes).
*/
public function test_aggregate_time_round_up_works_when_not_on_boundary(): void
{
// Arrange
// Create a time entry with duration NOT on a 15-minute boundary (48 minutes = 2880 seconds)
$project = Project::factory()->create();
TimeEntry::factory()->startWithDuration(
Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 13:00:00'),
2880 // 48 minutes, not on 15-minute boundary
)->forProject($project)->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
null,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
TimeEntryRoundingType::Up,
15
);
// Assert
// 48 minutes rounded up to 15-minute interval = 60 minutes (3600 seconds)
$this->assertEqualsCanonicalizing([
'seconds' => 3600, // 60 minutes
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 3600, // 60 minutes
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
], $result);
}
}