mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
5 Commits
feature/up
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb2d6d05e9 | ||
|
|
ec815d8c26 | ||
|
|
51af3db305 | ||
|
|
f242ce48b5 | ||
|
|
19064cdc3d |
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user