diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 75641fea..be6eb519 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -535,7 +535,7 @@ class TimeEntryController extends Controller ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename); } else { Excel::store( - new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate), + new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate, $localizationService), $path, config('filesystems.private'), $format->getExportPackageType(), diff --git a/app/Service/LocalizationService.php b/app/Service/LocalizationService.php index a03d1b7d..bee7490a 100644 --- a/app/Service/LocalizationService.php +++ b/app/Service/LocalizationService.php @@ -8,12 +8,14 @@ use App\Enums\CurrencyFormat; use App\Enums\DateFormat; use App\Enums\IntervalFormat; use App\Enums\NumberFormat; +use App\Enums\TimeEntryAggregationType; use App\Enums\TimeFormat; use App\Models\Organization; use Brick\Math\BigDecimal; use Brick\Money\Money; use Carbon\CarbonInterface; use Carbon\CarbonInterval; +use Illuminate\Support\Carbon; class LocalizationService { @@ -152,6 +154,29 @@ class LocalizationService return $date->format($this->dateFormat->toCarbonFormat()); } + /** + * Time group types have no server-side descriptor, so reports fall back to rendering the raw + * aggregation key. For the Day type that key is an ISO date (Y-m-d), which is formatted here + * so exports match the rest of the UI instead of leaking the key. + * + * Week, month and year keys have different shapes ('Y-m-d' for the week start, 'Y-m' and 'Y'), + * and are not offered as a grouping in the reporting UI, so they are returned unchanged. + * Presenting those needs its own decision: a week rendered as its start date reads as a single + * day, and a month rendered with a full date format reads as a specific day. + */ + public function formatTimeGroupKey(?string $key, TimeEntryAggregationType $groupType): ?string + { + if ($key === null) { + return null; + } + + if ($groupType !== TimeEntryAggregationType::Day) { + return $key; + } + + return $this->formatDate(Carbon::parse($key)); + } + public function setDateFormat(DateFormat $dateFormat): void { $this->dateFormat = $dateFormat; diff --git a/app/Service/ReportExport/TimeEntriesReportExport.php b/app/Service/ReportExport/TimeEntriesReportExport.php index 5b6f3048..3ae6e5ea 100644 --- a/app/Service/ReportExport/TimeEntriesReportExport.php +++ b/app/Service/ReportExport/TimeEntriesReportExport.php @@ -6,6 +6,7 @@ namespace App\Service\ReportExport; use App\Enums\ExportFormat; use App\Enums\TimeEntryAggregationType; +use App\Service\LocalizationService; use Illuminate\View\View; use Maatwebsite\Excel\Concerns\Exportable; use Maatwebsite\Excel\Concerns\FromView; @@ -48,6 +49,8 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv private bool $showBillableRate; + private LocalizationService $localization; + /** * @param array{ * grouped_type: string|null, @@ -68,7 +71,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv * cost: int|null * } $data */ - public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate) + public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate, LocalizationService $localization) { $this->data = $data; $this->exportFormat = $exportFormat; @@ -76,6 +79,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv $this->group = $group; $this->subGroup = $subGroup; $this->showBillableRate = $showBillableRate; + $this->localization = $localization; } public function view(): View @@ -87,6 +91,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv 'subGroup' => $this->subGroup, 'exportFormat' => $this->exportFormat, 'showBillableRate' => $this->showBillableRate, + 'localization' => $this->localization, ]); } diff --git a/resources/views/reports/time-entry-aggregate/pdf.blade.php b/resources/views/reports/time-entry-aggregate/pdf.blade.php index 4681cf83..9fc4472e 100644 --- a/resources/views/reports/time-entry-aggregate/pdf.blade.php +++ b/resources/views/reports/time-entry-aggregate/pdf.blade.php @@ -194,7 +194,7 @@ @if($group->is(\App\Enums\TimeEntryAggregationType::Billable)) {{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }} @else - {{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }} + {{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? 'No '.Str::lower($group->description()) }} @endif @@ -239,7 +239,7 @@ {{ $group->description() }}: - {{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }} + {{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? 'No '.Str::lower($group->description()) }} @endif @@ -278,7 +278,7 @@ @if($subGroup->is(\App\Enums\TimeEntryAggregationType::Billable)) {{ $group2Entry['key'] === '1' ? 'Billable' : 'Non-billable' }} @else - {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }} + {{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }} @endif @@ -318,7 +318,7 @@ series: [ { - data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group): object { + data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group, $localization): object { $color = $data['color']; if ($color === null) { $color = $colorService->getRandomColor($data['key']); @@ -328,7 +328,7 @@ } return (object)[ 'value' => $data['seconds'], - 'name' => $data['description'] ?? $data['key'] ?? 'No '.Str::lower($group->description()), + 'name' => $data['description'] ?? $localization->formatTimeGroupKey($data['key'], $group) ?? 'No '.Str::lower($group->description()), 'color' => $color, 'itemStyle' => (object) [ 'color' => $color, diff --git a/resources/views/reports/time-entry-aggregate/spreadsheet.blade.php b/resources/views/reports/time-entry-aggregate/spreadsheet.blade.php index 18e152ca..0dd514b2 100644 --- a/resources/views/reports/time-entry-aggregate/spreadsheet.blade.php +++ b/resources/views/reports/time-entry-aggregate/spreadsheet.blade.php @@ -44,7 +44,7 @@ @else - {{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }} + {{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? '-' }} @endif @if ($subGroup === TimeEntryAggregationType::Billable) @@ -53,7 +53,7 @@ @else - {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }} + {{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }} @endif @@ -74,7 +74,7 @@ @else - {{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }} + {{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? '-' }} @endif @if ($subGroup === TimeEntryAggregationType::Billable) @@ -83,7 +83,7 @@ @else - {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }} + {{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }} @endif assertResponseCode($response, 200); } + public function test_aggregate_export_endpoints_can_create_a_pdf_report_grouped_by_date(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create(); + $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create(); + Passport::actingAs($data->user); + $this->actAsOrganizationWithSubscription(); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::PDF, + 'group' => TimeEntryAggregationType::Day, + 'sub_group' => TimeEntryAggregationType::Project, + 'history_group' => TimeEntryAggregationTypeInterval::Day, + 'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(), + 'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(), + ])); + + // Assert + $this->assertResponseCode($response, 200); + } + + public function test_aggregate_export_pdf_renders_date_group_labels_in_organization_date_format(): void + { + // Arrange + $this->travelTo(Carbon::create(2024, 3, 15, 12, 0, 0, 'UTC')); + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + // Note: the organization factory randomizes the date format, so pin it + $data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]); + $project = Project::factory()->forOrganization($data->organization)->create(); + TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member) + ->startWithDuration(Carbon::now()->subDay(), 3600)->create(); + Passport::actingAs($data->user); + $this->actAsOrganizationWithSubscription(); + + // Act + // Note: debug=true returns the rendered HTML instead of handing it to the PDF renderer. + $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::PDF, + 'group' => TimeEntryAggregationType::Day, + 'sub_group' => TimeEntryAggregationType::Project, + 'history_group' => TimeEntryAggregationTypeInterval::Day, + 'start' => Carbon::now()->subDays(7)->toIso8601ZuluString(), + 'end' => Carbon::now()->toIso8601ZuluString(), + 'debug' => 'true', + ])); + + // Assert + $this->assertResponseCode($response, 200); + $html = $response->json('html'); + $this->assertIsString($html); + $this->assertStringContainsString('14/03/2024', $html); + $this->assertStringNotContainsString('2024-03-14', $html); + } + + public function test_aggregate_export_csv_renders_date_group_labels_in_organization_date_format(): void + { + // Arrange + $this->travelTo(Carbon::create(2024, 3, 15, 12, 0, 0, 'UTC')); + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + // Note: the organization factory randomizes the date format, so pin it + $data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]); + $project = Project::factory()->forOrganization($data->organization)->create(); + TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member) + ->startWithDuration(Carbon::now()->subDay(), 3600)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [ + $data->organization->getKey(), + 'format' => ExportFormat::CSV, + 'group' => TimeEntryAggregationType::Day, + 'sub_group' => TimeEntryAggregationType::Project, + 'history_group' => TimeEntryAggregationTypeInterval::Day, + 'start' => Carbon::now()->subDays(7)->toIso8601ZuluString(), + 'end' => Carbon::now()->toIso8601ZuluString(), + ])); + + // Assert + $this->assertResponseCode($response, 200); + $disk = Storage::disk(config('filesystems.private')); + $files = $disk->files('exports'); + $this->assertCount(1, $files); + $csv = $disk->get($files[0]); + $this->assertIsString($csv); + $this->assertStringContainsString('14/03/2024', $csv); + $this->assertStringNotContainsString('2024-03-14', $csv); + } + public function test_index_export_endpoint_with_client_ids_filter_returns_filtered_entries(): void { // Arrange diff --git a/tests/Unit/Service/LocalizationServiceTest.php b/tests/Unit/Service/LocalizationServiceTest.php index 8904eebe..ea601fba 100644 --- a/tests/Unit/Service/LocalizationServiceTest.php +++ b/tests/Unit/Service/LocalizationServiceTest.php @@ -8,6 +8,7 @@ use App\Enums\CurrencyFormat; use App\Enums\DateFormat; use App\Enums\IntervalFormat; use App\Enums\NumberFormat; +use App\Enums\TimeEntryAggregationType; use App\Enums\TimeFormat; use App\Service\LocalizationService; use Brick\Money\Currency; @@ -303,4 +304,39 @@ class LocalizationServiceTest extends TestCaseWithDatabase // Assert $this->assertSame('14:09', $formatted); } + + public function test_format_time_group_key_formats_a_day_key_with_the_date_format(): void + { + // Arrange + $this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY); + + // Act + $formatted = $this->localizationService->formatTimeGroupKey('2001-02-03', TimeEntryAggregationType::Day); + + // Assert + $this->assertSame('03/02/2001', $formatted); + } + + public function test_format_time_group_key_returns_null_for_a_null_key(): void + { + // Act + $formatted = $this->localizationService->formatTimeGroupKey(null, TimeEntryAggregationType::Day); + + // Assert + $this->assertNull($formatted); + } + + public function test_format_time_group_key_returns_the_key_unchanged_for_non_day_group_types(): void + { + // Arrange + $this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY); + + // Act & Assert + // Week/month/year are not offered as a grouping in the reporting UI and their keys have + // different shapes, so they are passed through rather than formatted as a date. + $this->assertSame('2001-02-03', $this->localizationService->formatTimeGroupKey('2001-02-03', TimeEntryAggregationType::Week)); + $this->assertSame('2001-02', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month)); + $this->assertSame('2001', $this->localizationService->formatTimeGroupKey('2001', TimeEntryAggregationType::Year)); + $this->assertSame('some-uuid', $this->localizationService->formatTimeGroupKey('some-uuid', TimeEntryAggregationType::Project)); + } }